From 199fc2d04369928920d7886f6c5d5c71b1db4fdf Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Sun, 24 May 2026 13:11:05 -0500 Subject: [PATCH] Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Save serverConfig to App Group on every app launch so ShareExtension can always find credentials without requiring a disconnect/reconnect flow - Add MarksWidget target with RandomBookmarkWidget (random saved bookmark, reloads hourly) using file-based App Group storage to avoid CFPreferences issues - Extract BookmarkListRow as shared component with editorial typography tweaks (17pt semibold title, 13pt domain, relative date, 32×32 favicon, italic AI summary, 20pt podcast icon, 16pt horizontal insets) - Add MarksWidget and ShareExtension Xcode schemes for direct run/debug Co-Authored-By: Claude Sonnet 4.6 --- Marks.xcodeproj/project.pbxproj | 176 +++++++++++++++- .../xcshareddata/xcschemes/Marks.xcscheme | 14 ++ .../xcschemes/MarksWidget.xcscheme | 105 ++++++++++ .../xcschemes/ShareExtension.xcscheme | 110 ++++++++++ Marks/Info.plist | 9 + Marks/MarksApp.swift | 48 +++++ Marks/Services/ClaudeService.swift | 4 +- Marks/Services/PodcastIndex.swift | 13 +- Marks/Views/BookmarkListRow.swift | 111 +++++++++++ Marks/Views/BookmarksView.swift | 157 +++++++-------- Marks/Views/BookmarksViewModel.swift | 3 + Marks/Views/BrowserView.swift | 188 ++++++++++++++---- Marks/Views/PodcastPlayerView.swift | 73 ++++++- Marks/Views/SearchView.swift | 33 +-- Marks/Views/TagsView.swift | 42 ++-- MarksWidget/Info.plist | 25 +++ MarksWidget/MarksWidget.entitlements | 10 + MarksWidget/MarksWidgetBundle.swift | 11 + MarksWidget/RandomBookmarkWidget.swift | 98 +++++++++ MarksWidget/RecentBookmarksWidget.swift | 108 ++++++++++ MarksWidget/RecentPodcastsWidget.swift | 111 +++++++++++ MarksWidget/WidgetDataStore.swift | 117 +++++++++++ project.yml | 43 ++++ 23 files changed, 1437 insertions(+), 172 deletions(-) create mode 100644 Marks.xcodeproj/xcshareddata/xcschemes/MarksWidget.xcscheme create mode 100644 Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme create mode 100644 Marks/Views/BookmarkListRow.swift create mode 100644 MarksWidget/Info.plist create mode 100644 MarksWidget/MarksWidget.entitlements create mode 100644 MarksWidget/MarksWidgetBundle.swift create mode 100644 MarksWidget/RandomBookmarkWidget.swift create mode 100644 MarksWidget/RecentBookmarksWidget.swift create mode 100644 MarksWidget/RecentPodcastsWidget.swift create mode 100644 MarksWidget/WidgetDataStore.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 7491d94..71a6654 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; }; - A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; @@ -16,24 +16,42 @@ 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; }; + 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; + 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; 8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; + 905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; + 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; }; 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; + A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; }; + AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; + B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; + CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; + DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; }; + E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; + E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 61046BF39C48393DD96687A0; + remoteInfo = MarksWidget; + }; BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */; @@ -51,6 +69,7 @@ dstSubfolderSpec = 13; files = ( B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */, + 0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -59,9 +78,17 @@ /* Begin PBXFileReference section */ 03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + 0552C13335034219DECF4F62 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = ""; }; + 093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = ""; }; + 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = ""; }; + 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = ""; }; + 55D369E6D2B8B036685BBD13 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = ""; }; @@ -71,6 +98,8 @@ 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = ""; }; + 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = ""; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = ""; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -80,15 +109,36 @@ BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; + CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; + DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = ""; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = ""; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + 6B2547BA0E17C2564677B34A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A1E820C4B9604F8F05055D3E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */, + 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 3952A081972B444674BEB51B /* Marks */ = { isa = PBXGroup; @@ -127,8 +177,8 @@ 7ABBFDF43A1EB773E0A49CFE /* Services */ = { isa = PBXGroup; children = ( - A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */, - 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, + 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */, + 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, @@ -136,10 +186,25 @@ path = Services; sourceTree = ""; }; + 85E717A515682EBF67DD199A /* MarksWidget */ = { + isa = PBXGroup; + children = ( + 55D369E6D2B8B036685BBD13 /* Info.plist */, + 969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */, + DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */, + 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */, + 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */, + 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */, + 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */, + ); + path = MarksWidget; + sourceTree = ""; + }; AF9CABF75E77168EF1D60F29 /* Views */ = { isa = PBXGroup; children = ( AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, + CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, @@ -159,7 +224,9 @@ isa = PBXGroup; children = ( 3952A081972B444674BEB51B /* Marks */, + 85E717A515682EBF67DD199A /* MarksWidget */, 7A85D09B4E109E1D9CD8F5BB /* ShareExtension */, + F5B3C85424137FA0CE2467B1 /* Frameworks */, C6978BB2E0A71E9A349D5EB6 /* Products */, ); sourceTree = ""; @@ -168,26 +235,56 @@ isa = PBXGroup; children = ( AB6C53AB14A38FCD4CC7628D /* Marks.app */, + 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */, 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */, ); name = Products; sourceTree = ""; }; + F5B3C85424137FA0CE2467B1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 093402014727A338745A06DA /* SwiftUI.framework */, + 0552C13335034219DECF4F62 /* WidgetKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 61046BF39C48393DD96687A0 /* MarksWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */; + buildPhases = ( + 2B3C202ACE4B7248E24601BF /* Sources */, + A1E820C4B9604F8F05055D3E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MarksWidget; + packageProductDependencies = ( + ); + productName = MarksWidget; + productReference = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; 76C272776D8EBCCD3FB6715A /* Marks */ = { isa = PBXNativeTarget; buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */; buildPhases = ( 81AF93AC59791A6311B9055B /* Sources */, D56AC7DEA48961F856A6CEF6 /* Resources */, + 6B2547BA0E17C2564677B34A /* Frameworks */, 93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( A87C510E52F149E7E3CA0283 /* PBXTargetDependency */, + 93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */, ); name = Marks; packageProductDependencies = ( @@ -222,6 +319,10 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1630; TargetAttributes = { + 61046BF39C48393DD96687A0 = { + DevelopmentTeam = AE5DZKJHGN; + ProvisioningStyle = Automatic; + }; 76C272776D8EBCCD3FB6715A = { DevelopmentTeam = AE5DZKJHGN; ProvisioningStyle = Automatic; @@ -247,6 +348,7 @@ projectRoot = ""; targets = ( 76C272776D8EBCCD3FB6715A /* Marks */, + 61046BF39C48393DD96687A0 /* MarksWidget */, CF31B2655F8F30CF6DF3C26F /* ShareExtension */, ); }; @@ -272,18 +374,31 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2B3C202ACE4B7248E24601BF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */, + 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */, + 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */, + 905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */, + E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 81AF93AC59791A6311B9055B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, + B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, + CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */, - A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */, - BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, + BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, @@ -297,12 +412,18 @@ 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */, + E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 61046BF39C48393DD96687A0 /* MarksWidget */; + targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */; + }; A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */; @@ -422,6 +543,23 @@ }; name = Release; }; + 6CDF13C33FDD1141CFE677AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements; + INFOPLIST_FILE = MarksWidget/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 7AD80830605CAD3492E03039 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -506,6 +644,23 @@ }; name = Debug; }; + D8AB62E7173CC10C6EB15373 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements; + INFOPLIST_FILE = MarksWidget/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -536,6 +691,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6CDF13C33FDD1141CFE677AD /* Debug */, + D8AB62E7173CC10C6EB15373 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */; diff --git a/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme b/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme index 7ebd312..38e25a7 100644 --- a/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme +++ b/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:Marks.xcodeproj"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme b/Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme new file mode 100644 index 0000000..f18cdd2 --- /dev/null +++ b/Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Marks/Info.plist b/Marks/Info.plist index 662dabe..6144e62 100644 --- a/Marks/Info.plist +++ b/Marks/Info.plist @@ -28,5 +28,14 @@ audio + CFBundleURLTypes + + + CFBundleURLSchemes + + marks + + + diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 31fcc0e..77a4681 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -18,16 +18,24 @@ struct MarksApp: App { defaultConfig.save() serverConfig = defaultConfig } + .task { serverConfig.save() } } } } +private struct IdentifiableURL: Identifiable { + let id = UUID() + let url: URL +} + // Holds the API + ViewModel so they survive re-renders struct MainContainer: View { let config: ServerConfig let onDisconnect: () -> Void @State private var viewModel: BookmarksViewModel + @State private var deepLinkBrowser: IdentifiableURL? + @State private var showDeepLinkPlayer = false init(config: ServerConfig, onDisconnect: @escaping () -> Void) { self.config = config @@ -48,5 +56,45 @@ struct MainContainer: View { SearchView(viewModel: viewModel) } } + .onOpenURL { url in + handleDeepLink(url) + } + .sheet(item: $deepLinkBrowser) { item in + BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) + } + .sheet(isPresented: $showDeepLinkPlayer) { + PodcastPlayerView( + vm: viewModel.podcastPlayer, + articleUrl: viewModel.podcastPlayer.currentArticleUrl, + articleTitle: viewModel.podcastPlayer.currentArticleTitle, + claude: viewModel.claude, + stopOnDismiss: false + ) + } + } + + private func handleDeepLink(_ url: URL) { + guard url.scheme == "marks", + let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), + let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value + else { return } + + switch url.host { + case "bookmark": + if let browserURL = URL(string: articleUrl) { + deepLinkBrowser = IdentifiableURL(url: browserURL) + } + case "podcast": + let podcasts = WidgetDataStore.loadPodcasts() + let found = podcasts.first { $0.articleUrl == articleUrl } + viewModel.podcastPlayer.start( + articleUrl: articleUrl, + articleTitle: found?.title ?? "", + claude: viewModel.claude + ) + showDeepLinkPlayer = true + default: + break + } } } diff --git a/Marks/Services/ClaudeService.swift b/Marks/Services/ClaudeService.swift index a8cebe9..1ea39ad 100644 --- a/Marks/Services/ClaudeService.swift +++ b/Marks/Services/ClaudeService.swift @@ -67,13 +67,13 @@ struct ClaudeService: Sendable { return try JSONDecoder().decode(PodcastStatus.self, from: data) } - func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil) async throws -> URL { + func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL { let dest = ClaudeService.cachedPodcastURL(for: articleUrl) try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true) let data = try await get("/v1/podcast/audio/\(jobId)") try data.write(to: dest, options: .atomic) - PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title) + PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl) return dest } diff --git a/Marks/Services/PodcastIndex.swift b/Marks/Services/PodcastIndex.swift index c023199..a9c6345 100644 --- a/Marks/Services/PodcastIndex.swift +++ b/Marks/Services/PodcastIndex.swift @@ -6,6 +6,7 @@ struct PodcastEntry: Codable, Identifiable { let filename: String var title: String? let createdAt: Date + var parentBookmarkUrl: String? } enum PodcastIndex { @@ -35,14 +36,19 @@ enum PodcastIndex { return entries.sorted { $0.createdAt > $1.createdAt } } - static func upsert(articleUrl: String, filename: String, title: String?) { + static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) { var entries = all() entries.removeAll { $0.articleUrl == articleUrl } entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename, - title: title, createdAt: Date()), at: 0) + title: title, createdAt: Date(), + parentBookmarkUrl: parentBookmarkUrl), at: 0) save(entries) } + static func find(for bookmarkUrl: String) -> [PodcastEntry] { + all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl } + } + static func remove(articleUrl: String) { var entries = all() entries.removeAll { $0.articleUrl == articleUrl } @@ -52,5 +58,8 @@ enum PodcastIndex { private static func save(_ entries: [PodcastEntry]) { guard let data = try? encoder.encode(entries) else { return } try? data.write(to: indexURL, options: .atomic) + WidgetDataStore.savePodcasts(entries.prefix(10).map { + WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt) + }) } } diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift new file mode 100644 index 0000000..110e564 --- /dev/null +++ b/Marks/Views/BookmarkListRow.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView. +/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet. +struct BookmarkListRow: View { + let bookmark: Bookmark + let viewModel: BookmarksViewModel + var readingProgress: Double = 0 + let onOpen: () -> Void + var onEdit: (() -> Void)? = nil + + @Environment(\.openURL) private var openURL + @State private var showFullPlayer = false + @State private var episodePickerBookmark: Bookmark? + + var body: some View { + Button { onOpen() } label: { + BookmarkRow( + bookmark: bookmark, + readingProgress: readingProgress, + onPodcast: handlePodcast + ) + } + .buttonStyle(RowPressStyle()) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listRowSeparator(.visible) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { await viewModel.delete(bookmark) } + } label: { + Label("Delete", systemImage: "trash") + } + } + .swipeActions(edge: .leading) { + Button { + Task { await viewModel.archive(bookmark) } + } label: { + Label("Archive", systemImage: "archivebox") + } + .tint(.orange) + if let onEdit { + Button { onEdit() } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + .contextMenu { + if let onEdit { + Button { onEdit() } label: { + Label("Edit", systemImage: "pencil") + } + } + Button { onOpen() } label: { + Label("Open", systemImage: "globe") + } + Button { + if let url = URL(string: bookmark.url) { openURL(url) } + } label: { + Label("Open in Safari", systemImage: "safari") + } + Button { handlePodcast() } label: { + Label("Convert to Podcast", systemImage: "headphones") + } + Divider() + Button { + Task { await viewModel.archive(bookmark) } + } label: { + Label("Archive", systemImage: "archivebox") + } + Button(role: .destructive) { + Task { await viewModel.delete(bookmark) } + } label: { + Label("Delete", systemImage: "trash") + } + } + .sheet(isPresented: $showFullPlayer) { + PodcastPlayerView( + vm: viewModel.podcastPlayer, + articleUrl: viewModel.podcastPlayer.currentArticleUrl, + articleTitle: viewModel.podcastPlayer.currentArticleTitle, + claude: viewModel.claude, + stopOnDismiss: false + ) + } + .sheet(item: $episodePickerBookmark) { b in + EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude) + } + } + + private func handlePodcast() { + let episodes = PodcastIndex.find(for: bookmark.url) + if episodes.count >= 2 { + episodePickerBookmark = bookmark + } else if let ep = episodes.first { + viewModel.podcastPlayer.start( + articleUrl: ep.articleUrl, + articleTitle: ep.title ?? bookmark.displayTitle, + claude: viewModel.claude + ) + showFullPlayer = true + } else { + viewModel.podcastPlayer.start( + articleUrl: bookmark.url, + articleTitle: bookmark.displayTitle, + claude: viewModel.claude + ) + showFullPlayer = true + } + } +} diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index e423a8f..a951afa 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -1,10 +1,57 @@ import SwiftUI +// MARK: - Skeleton loading row + +private struct SkeletonRow: View { + var delay: Double = 0 + @State private var dimmed = false + + var body: some View { + HStack(alignment: .top, spacing: 11) { + RoundedRectangle(cornerRadius: 7) + .fill(Color(.systemGray5)) + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 7) { + Capsule() + .fill(Color(.systemGray5)) + .frame(maxWidth: .infinity) + .frame(height: 13) + Capsule() + .fill(Color(.systemGray6)) + .frame(width: 140, height: 10) + } + .padding(.top, 4) + Spacer() + } + .padding(.vertical, 14) + .opacity(dimmed ? 0.45 : 1) + .onAppear { + withAnimation( + .easeInOut(duration: 0.9) + .repeatForever(autoreverses: true) + .delay(delay) + ) { dimmed = true } + } + } +} + +// MARK: - Row press ButtonStyle + +struct RowPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .opacity(configuration.isPressed ? 0.75 : 1) + .animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed) + } +} + +// MARK: - BookmarksView + struct BookmarksView: View { @Bindable var viewModel: BookmarksViewModel let onDisconnect: () -> Void - @Environment(\.openURL) private var openURL @State private var showSettings = false @State private var showCollections = false @State private var showAddBookmark = false @@ -17,84 +64,23 @@ struct BookmarksView: View { var body: some View { NavigationStack { List { - ForEach(viewModel.bookmarks) { bookmark in - BookmarkRow( - bookmark: bookmark, - readingProgress: readingProgress[bookmark.url] ?? 0, - onPodcast: { - viewModel.podcastPlayer.start( - articleUrl: bookmark.url, - articleTitle: bookmark.displayTitle, - claude: viewModel.claude - ) - showFullPlayer = true - } - ) - .onTapGesture { - browsingBookmark = bookmark + if viewModel.isLoading && viewModel.bookmarks.isEmpty { + ForEach(0..<3, id: \.self) { i in + SkeletonRow(delay: Double(i) * 0.13) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listRowSeparator(.visible) } + } else { + ForEach(viewModel.bookmarks) { bookmark in + BookmarkListRow( + bookmark: bookmark, + viewModel: viewModel, + readingProgress: readingProgress[bookmark.url] ?? 0, + onOpen: { browsingBookmark = bookmark }, + onEdit: { editingBookmark = bookmark } + ) .onAppear { maybeLoadMore(bookmark) } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .listRowSeparator(.visible) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - Task { await viewModel.delete(bookmark) } - } label: { - Label("Delete", systemImage: "trash") - } - } - .swipeActions(edge: .leading) { - Button { - Task { await viewModel.archive(bookmark) } - } label: { - Label("Archive", systemImage: "archivebox") - } - .tint(.orange) - Button { - editingBookmark = bookmark - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(.blue) - } - .contextMenu { - Button { - editingBookmark = bookmark - } label: { - Label("Edit", systemImage: "pencil") - } - Button { - browsingBookmark = bookmark - } label: { - Label("Open", systemImage: "globe") - } - Button { - if let url = URL(string: bookmark.url) { openURL(url) } - } label: { - Label("Open in Safari", systemImage: "safari") - } - Button { - viewModel.podcastPlayer.start( - articleUrl: bookmark.url, - articleTitle: bookmark.displayTitle, - claude: viewModel.claude - ) - showFullPlayer = true - } label: { - Label("Convert to Podcast", systemImage: "headphones") - } - Divider() - Button { - Task { await viewModel.archive(bookmark) } - } label: { - Label("Archive", systemImage: "archivebox") - } - Button(role: .destructive) { - Task { await viewModel.delete(bookmark) } - } label: { - Label("Delete", systemImage: "trash") - } - } + } } if viewModel.isLoadingMore { @@ -103,6 +89,7 @@ struct BookmarksView: View { } } .listStyle(.plain) + .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationBarTitleDisplayMode(.large) .toolbar { @@ -139,6 +126,7 @@ struct BookmarksView: View { Image(systemName: viewModel.unreadFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + .contentTransition(.symbolEffect(.replace)) } Button { showSettings = true } label: { Image(systemName: "gearshape") @@ -147,11 +135,13 @@ struct BookmarksView: View { } } .overlay { - if viewModel.isLoading && viewModel.bookmarks.isEmpty { - ProgressView() - } if !viewModel.isLoading && viewModel.bookmarks.isEmpty { - ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here.")) + ContentUnavailableView( + "No Bookmarks", + systemImage: "bookmark", + description: Text("Bookmarks you save will appear here.") + ) + .transition(.opacity) } } .overlay(alignment: .bottom) { @@ -169,6 +159,7 @@ struct BookmarksView: View { .animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty) .padding(.bottom, 8) } + .sensoryFeedback(.selection, trigger: browsingBookmark?.id) } .sheet(isPresented: $showSettings) { SettingsView(viewModel: viewModel, onDisconnect: onDisconnect) @@ -184,7 +175,9 @@ struct BookmarksView: View { } .sheet(item: $browsingBookmark) { bookmark in if let url = URL(string: bookmark.url) { - BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude) + BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) { + await viewModel.archive(bookmark) + } } } .onChange(of: browsingBookmark) { _, new in diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index c7d9af7..d0ecc03 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -39,6 +39,9 @@ final class BookmarksViewModel { nextPageUrl = response.next restoreAIData() saveToCache(bookmarks) + WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map { + WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl) + }) } catch { self.error = error.localizedDescription } diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index c0935a6..dd71346 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -25,6 +25,29 @@ enum ReadingProgress { } } +// MARK: - Indeterminate page load bar + +private struct PageLoadBar: View { + @State private var phase: CGFloat = 0 + + var body: some View { + GeometryReader { geo in + let barWidth = geo.size.width * 0.38 + Capsule() + .fill(.blue.opacity(0.75)) + .frame(width: barWidth, height: 3) + .offset(x: (phase * (geo.size.width + barWidth)) - barWidth) + } + .frame(height: 3) + .clipped() + .onAppear { + withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} + // MARK: - BrowserState @Observable @@ -196,18 +219,25 @@ struct BrowserView: View { let initialURL: URL let initialTitle: String let claude: ClaudeService + let podcastPlayer: PodcastPlayerViewModel + var onArchive: (() async -> Void)? = nil @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var state: BrowserState @State private var readerMode = false @State private var showPodcast = false - @State private var podcastVM = PodcastPlayerViewModel() + @State private var archiveTapCount = 0 + @State private var navBackCount = 0 + @State private var navForwardCount = 0 + @State private var toolbarHidden = false - init(url: URL, title: String, claude: ClaudeService) { + init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) { self.initialURL = url self.initialTitle = title self.claude = claude + self.podcastPlayer = podcastPlayer + self.onArchive = onArchive self._state = State(initialValue: BrowserState(url: url)) } @@ -221,16 +251,17 @@ struct BrowserView: View { if state.readingProgress > 0.01 && state.readingProgress < 0.99 { GeometryReader { geo in Rectangle() - .fill(Color.blue.opacity(0.7)) + .fill(Color.blue.opacity(0.55)) .frame(width: geo.size.width * state.readingProgress, height: 3) } .frame(height: 3) .animation(.linear(duration: 0.1), value: state.readingProgress) } + // Page loading sweep bar (replaces center spinner) if state.isLoading { - ProgressView() - .padding(.top, 8) + PageLoadBar() + .transition(.opacity) } } .navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle) @@ -247,44 +278,28 @@ struct BrowserView: View { Image(systemName: "safari") } } - ToolbarItemGroup(placement: .bottomBar) { - Button { state.webView.goBack() } label: { - Image(systemName: "chevron.left") - } - .disabled(!state.canGoBack) - - Spacer() - - Button { state.webView.goForward() } label: { - Image(systemName: "chevron.right") - } - .disabled(!state.canGoForward) - - Spacer() - - Button { - if readerMode { state.exitReaderMode(); readerMode = false } - else { state.enterReaderMode { readerMode = true } } - } label: { - Image(systemName: readerMode ? "doc.text.fill" : "doc.text") - } - .disabled(state.isLoading) - - Spacer() - - Button { showPodcast = true } label: { - Image(systemName: "headphones") - } - .disabled(state.isLoading) - - Spacer() - - ShareLink(item: state.currentURL ?? initialURL) { - Image(systemName: "square.and.arrow.up") - } + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + bottomBar + .offset(y: toolbarHidden ? 88 : 0) + .animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden) + } + .onChange(of: state.readingProgress) { old, new in + let delta = new - old + withAnimation(.spring(duration: 0.3, bounce: 0)) { + if delta > 0.012 && new > 0.07 { + toolbarHidden = true + } else if delta < -0.005 || new < 0.05 { + toolbarHidden = false } } } + .onChange(of: state.isLoading) { _, loading in + if loading { + withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false } + } + } .onDisappear { let urlStr = (state.currentURL ?? initialURL).absoluteString let p = state.readingProgress @@ -292,12 +307,101 @@ struct BrowserView: View { } .sheet(isPresented: $showPodcast) { PodcastPlayerView( - vm: podcastVM, + vm: podcastPlayer, articleUrl: (state.currentURL ?? initialURL).absoluteString, articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle, claude: claude, - stopOnDismiss: true + stopOnDismiss: false ) } } + + private var bottomBar: some View { + HStack { + Button { + navBackCount += 1 + state.webView.goBack() + } label: { + Image(systemName: "chevron.left") + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .disabled(!state.canGoBack) + .sensoryFeedback(.impact(weight: .light), trigger: navBackCount) + + Spacer() + + Button { + navForwardCount += 1 + state.webView.goForward() + } label: { + Image(systemName: "chevron.right") + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .disabled(!state.canGoForward) + .sensoryFeedback(.impact(weight: .light), trigger: navForwardCount) + + Spacer() + + Button { + if readerMode { state.exitReaderMode(); readerMode = false } + else { state.enterReaderMode { readerMode = true } } + } label: { + Image(systemName: readerMode ? "doc.text.fill" : "doc.text") + .contentTransition(.symbolEffect(.replace)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .disabled(state.isLoading) + + if onArchive != nil { Spacer() } + + if let onArchive { + Button { + archiveTapCount += 1 + Task { await onArchive() } + dismiss() + } label: { + Image(systemName: "archivebox") + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount) + } + + Spacer() + + Button { + let currentUrl = (state.currentURL ?? initialURL).absoluteString + let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle + podcastPlayer.start( + articleUrl: currentUrl, + articleTitle: currentTitle, + claude: claude, + parentBookmarkUrl: initialURL.absoluteString + ) + showPodcast = true + } label: { + Image(systemName: "headphones") + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .disabled(state.isLoading) + + Spacer() + + ShareLink(item: state.currentURL ?? initialURL) { + Image(systemName: "square.and.arrow.up") + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + } + .font(.system(size: 17)) + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(.bar) + .overlay(alignment: .top) { Divider() } + } } diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 868313f..4836a02 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -59,7 +59,7 @@ final class PodcastPlayerViewModel { private var interruptionObserver: Any? private var currentPodcastTitle: String = "Marks Podcast" - func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) { + func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) { // Idempotent — don't restart if already generating or playing this article if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return } @@ -90,7 +90,7 @@ final class PodcastPlayerViewModel { ) if job.isDone { phase = .downloading - let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title) + let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title, parentBookmarkUrl: parentBookmarkUrl) setupPlayer(url: audioUrl, title: job.title ?? "Podcast") return } @@ -674,3 +674,72 @@ struct PodcastLibraryView: View { if vm.currentArticleUrl == entry.articleUrl { vm.stop() } } } + +// MARK: - Episode picker (multiple episodes per bookmark) + +struct EpisodePickerView: View { + let bookmark: Bookmark + let vm: PodcastPlayerViewModel + let claude: ClaudeService + + @State private var entries: [PodcastEntry] = [] + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(entries) { entry in + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.title ?? entry.articleUrl) + .font(.system(size: 15, weight: .medium)) + .lineLimit(2) + if entry.articleUrl != bookmark.url { + Text(entry.articleUrl) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + vm.start(articleUrl: entry.articleUrl, + articleTitle: entry.title ?? bookmark.displayTitle, + claude: claude) + dismiss() + } label: { + Image(systemName: "play.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } + .padding(.vertical, 6) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { deleteEntry(entry) } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + .listStyle(.plain) + .navigationTitle("Episodes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .onAppear { entries = PodcastIndex.find(for: bookmark.url) } + } + + private func deleteEntry(_ entry: PodcastEntry) { + PodcastIndex.remove(articleUrl: entry.articleUrl) + entries.removeAll { $0.articleUrl == entry.articleUrl } + if vm.currentArticleUrl == entry.articleUrl { vm.stop() } + } +} diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index a495f27..ff5f263 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -2,30 +2,22 @@ import SwiftUI struct SearchView: View { @Bindable var viewModel: BookmarksViewModel - @Environment(\.openURL) private var openURL @State private var searchText = "" @State private var useSemanticSearch = false @State private var semanticResults: [Bookmark]? = nil @State private var searchTask: Task? + @State private var browsingBookmark: Bookmark? var body: some View { NavigationStack { List { ForEach(results) { bookmark in - BookmarkRow(bookmark: bookmark) - .onTapGesture { - if let url = URL(string: bookmark.url) { openURL(url) } - } - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - .listRowSeparator(.visible) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - Task { await viewModel.delete(bookmark) } - } label: { - Label("Delete", systemImage: "trash") - } - } + BookmarkListRow( + bookmark: bookmark, + viewModel: viewModel, + onOpen: { browsingBookmark = bookmark } + ) } } .listStyle(.plain) @@ -49,6 +41,19 @@ struct SearchView: View { ProgressView() } } + .sensoryFeedback(.selection, trigger: browsingBookmark?.id) + .sheet(item: $browsingBookmark) { bookmark in + if let url = URL(string: bookmark.url) { + BrowserView( + url: url, + title: bookmark.displayTitle, + claude: viewModel.claude, + podcastPlayer: viewModel.podcastPlayer + ) { + await viewModel.archive(bookmark) + } + } + } } .searchable(text: $searchText, prompt: "Titles, URLs, tags…") .onChange(of: searchText) { _, _ in scheduleSearch() } diff --git a/Marks/Views/TagsView.swift b/Marks/Views/TagsView.swift index 4d33983..524dbb9 100644 --- a/Marks/Views/TagsView.swift +++ b/Marks/Views/TagsView.swift @@ -48,32 +48,17 @@ struct TagsView: View { struct TagBookmarksView: View { let tag: String @Bindable var viewModel: BookmarksViewModel - @Environment(\.openURL) private var openURL + + @State private var browsingBookmark: Bookmark? var body: some View { List { ForEach(filteredBookmarks) { bookmark in - BookmarkRow(bookmark: bookmark) - .onTapGesture { - if let url = URL(string: bookmark.url) { openURL(url) } - } - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - .listRowSeparator(.visible) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - Task { await viewModel.delete(bookmark) } - } label: { - Label("Delete", systemImage: "trash") - } - } - .swipeActions(edge: .leading) { - Button { - Task { await viewModel.archive(bookmark) } - } label: { - Label("Archive", systemImage: "archivebox") - } - .tint(.orange) - } + BookmarkListRow( + bookmark: bookmark, + viewModel: viewModel, + onOpen: { browsingBookmark = bookmark } + ) } } .listStyle(.plain) @@ -84,6 +69,19 @@ struct TagBookmarksView: View { ContentUnavailableView("No Bookmarks", systemImage: "tag") } } + .sensoryFeedback(.selection, trigger: browsingBookmark?.id) + .sheet(item: $browsingBookmark) { bookmark in + if let url = URL(string: bookmark.url) { + BrowserView( + url: url, + title: bookmark.displayTitle, + claude: viewModel.claude, + podcastPlayer: viewModel.podcastPlayer + ) { + await viewModel.archive(bookmark) + } + } + } } private var filteredBookmarks: [Bookmark] { diff --git a/MarksWidget/Info.plist b/MarksWidget/Info.plist new file mode 100644 index 0000000..bf5ffa0 --- /dev/null +++ b/MarksWidget/Info.plist @@ -0,0 +1,25 @@ + + + + + CFBundleDisplayName + MarksWidget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/MarksWidget/MarksWidget.entitlements b/MarksWidget/MarksWidget.entitlements new file mode 100644 index 0000000..02a7360 --- /dev/null +++ b/MarksWidget/MarksWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.magicive.marks + + + diff --git a/MarksWidget/MarksWidgetBundle.swift b/MarksWidget/MarksWidgetBundle.swift new file mode 100644 index 0000000..afbf9e3 --- /dev/null +++ b/MarksWidget/MarksWidgetBundle.swift @@ -0,0 +1,11 @@ +import WidgetKit +import SwiftUI + +@main +struct MarksWidgetBundle: WidgetBundle { + var body: some Widget { + RecentBookmarksWidget() + RandomBookmarkWidget() + RecentPodcastsWidget() + } +} diff --git a/MarksWidget/RandomBookmarkWidget.swift b/MarksWidget/RandomBookmarkWidget.swift new file mode 100644 index 0000000..c0f1927 --- /dev/null +++ b/MarksWidget/RandomBookmarkWidget.swift @@ -0,0 +1,98 @@ +import WidgetKit +import SwiftUI + +struct RandomBookmarkEntry: TimelineEntry { + let date: Date + let bookmark: WidgetBookmark? + + static let placeholder = RandomBookmarkEntry( + date: .now, + bookmark: WidgetBookmark(url: "https://example.com", title: "A Saved Article Worth Revisiting", domain: "example.com", faviconUrl: nil) + ) +} + +struct RandomBookmarkProvider: TimelineProvider { + func placeholder(in context: Context) -> RandomBookmarkEntry { .placeholder } + + func getSnapshot(in context: Context, completion: @escaping (RandomBookmarkEntry) -> Void) { + let bookmarks = WidgetDataStore.loadBookmarks() + completion(RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let bookmarks = WidgetDataStore.loadBookmarks() + let entry = RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement()) + let next = Calendar.current.date(byAdding: .hour, value: 1, to: .now)! + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +struct RandomBookmarkWidgetView: View { + let entry: RandomBookmarkEntry + + var body: some View { + Group { + if let bookmark = entry.bookmark { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Rediscover") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: "sparkles") + .font(.system(size: 11)) + .foregroundStyle(.blue) + } + Spacer() + RoundedRectangle(cornerRadius: 7) + .fill(Color.blue.opacity(0.1)) + .frame(width: 38, height: 38) + .overlay { + Image(systemName: "bookmark.fill") + .font(.system(size: 15)) + .foregroundStyle(.blue) + } + Spacer().frame(height: 10) + Text(bookmark.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + Text(bookmark.domain) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .widgetURL(.marksBookmark(bookmark.url)) + } else { + VStack(spacing: 8) { + Image(systemName: "bookmark") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text("Open Marks to\nload bookmarks.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .containerBackground(.fill.tertiary, for: .widget) + } +} + +struct RandomBookmarkWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: "com.magicive.marks.RandomBookmark", + provider: RandomBookmarkProvider() + ) { entry in + RandomBookmarkWidgetView(entry: entry) + } + .configurationDisplayName("Rediscover") + .description("A random saved bookmark to revisit.") + .supportedFamilies([.systemSmall]) + } +} diff --git a/MarksWidget/RecentBookmarksWidget.swift b/MarksWidget/RecentBookmarksWidget.swift new file mode 100644 index 0000000..c12942a --- /dev/null +++ b/MarksWidget/RecentBookmarksWidget.swift @@ -0,0 +1,108 @@ +import WidgetKit +import SwiftUI + +struct RecentBookmarksEntry: TimelineEntry { + let date: Date + let bookmarks: [WidgetBookmark] + + static let placeholder = RecentBookmarksEntry(date: .now, bookmarks: [ + WidgetBookmark(url: "https://example.com/a", title: "The Future of AI in 2025", domain: "example.com", faviconUrl: nil), + WidgetBookmark(url: "https://news.com/b", title: "Why Swift Concurrency Matters", domain: "news.com", faviconUrl: nil), + WidgetBookmark(url: "https://blog.dev/c", title: "Building Great iOS Apps", domain: "blog.dev", faviconUrl: nil), + ]) +} + +struct RecentBookmarksProvider: TimelineProvider { + func placeholder(in context: Context) -> RecentBookmarksEntry { .placeholder } + + func getSnapshot(in context: Context, completion: @escaping (RecentBookmarksEntry) -> Void) { + let bookmarks = WidgetDataStore.loadBookmarks() + completion(RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3)))) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let bookmarks = WidgetDataStore.loadBookmarks() + let entry = RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3))) + let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)! + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +struct RecentBookmarksWidgetView: View { + let entry: RecentBookmarksEntry + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Recent") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: "bookmark.fill") + .font(.system(size: 11)) + .foregroundStyle(.blue) + } + .padding(.bottom, 8) + + if entry.bookmarks.isEmpty { + Spacer() + Text("Save bookmarks to see them here.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Spacer() + } else { + ForEach(Array(entry.bookmarks.enumerated()), id: \.element.id) { idx, bookmark in + if idx > 0 { Divider().padding(.vertical, 5) } + Link(destination: .marksBookmark(bookmark.url)) { + WidgetBookmarkRow(bookmark: bookmark) + } + } + Spacer(minLength: 0) + } + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .containerBackground(.fill.tertiary, for: .widget) + } +} + +struct WidgetBookmarkRow: View { + let bookmark: WidgetBookmark + + var body: some View { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 5) + .fill(Color(.systemGray5)) + .frame(width: 26, height: 26) + .overlay { + Image(systemName: "bookmark") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Color(.systemGray2)) + } + VStack(alignment: .leading, spacing: 1) { + Text(bookmark.title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(bookmark.domain) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } +} + +struct RecentBookmarksWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: "com.magicive.marks.RecentBookmarks", + provider: RecentBookmarksProvider() + ) { entry in + RecentBookmarksWidgetView(entry: entry) + } + .configurationDisplayName("Recent Bookmarks") + .description("Your latest saved bookmarks.") + .supportedFamilies([.systemMedium]) + } +} diff --git a/MarksWidget/RecentPodcastsWidget.swift b/MarksWidget/RecentPodcastsWidget.swift new file mode 100644 index 0000000..925517d --- /dev/null +++ b/MarksWidget/RecentPodcastsWidget.swift @@ -0,0 +1,111 @@ +import WidgetKit +import SwiftUI + +struct RecentPodcastsEntry: TimelineEntry { + let date: Date + let podcasts: [WidgetPodcast] + + static let placeholder = RecentPodcastsEntry(date: .now, podcasts: [ + WidgetPodcast(articleUrl: "https://example.com/a", title: "The Future of AI", createdAt: .now), + WidgetPodcast(articleUrl: "https://news.com/b", title: "Swift Concurrency Deep Dive", createdAt: .now.addingTimeInterval(-86400)), + WidgetPodcast(articleUrl: "https://blog.dev/c", title: "Building Great iOS Apps", createdAt: .now.addingTimeInterval(-172800)), + ]) +} + +struct RecentPodcastsProvider: TimelineProvider { + func placeholder(in context: Context) -> RecentPodcastsEntry { .placeholder } + + func getSnapshot(in context: Context, completion: @escaping (RecentPodcastsEntry) -> Void) { + let podcasts = WidgetDataStore.loadPodcasts() + completion(RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3)))) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let podcasts = WidgetDataStore.loadPodcasts() + let entry = RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3))) + let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)! + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +struct RecentPodcastsWidgetView: View { + let entry: RecentPodcastsEntry + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Podcasts") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: "headphones") + .font(.system(size: 11)) + .foregroundStyle(.blue) + } + .padding(.bottom, 8) + + if entry.podcasts.isEmpty { + Spacer() + Text("Generate podcasts from your bookmarks to see them here.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Spacer() + } else { + ForEach(Array(entry.podcasts.enumerated()), id: \.element.id) { idx, podcast in + if idx > 0 { Divider().padding(.vertical, 5) } + Link(destination: .marksPodcast(podcast.articleUrl)) { + WidgetPodcastRow(podcast: podcast) + } + } + Spacer(minLength: 0) + } + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .containerBackground(.fill.tertiary, for: .widget) + } +} + +struct WidgetPodcastRow: View { + let podcast: WidgetPodcast + + var body: some View { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 5) + .fill(Color.blue.opacity(0.1)) + .frame(width: 26, height: 26) + .overlay { + Image(systemName: "waveform") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.blue) + } + VStack(alignment: .leading, spacing: 1) { + Text(podcast.title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(podcast.createdAt, style: .relative) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Image(systemName: "play.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(.blue.opacity(0.8)) + } + } +} + +struct RecentPodcastsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: "com.magicive.marks.RecentPodcasts", + provider: RecentPodcastsProvider() + ) { entry in + RecentPodcastsWidgetView(entry: entry) + } + .configurationDisplayName("Recent Podcasts") + .description("Tap to play recently generated podcasts.") + .supportedFamilies([.systemMedium]) + } +} diff --git a/MarksWidget/WidgetDataStore.swift b/MarksWidget/WidgetDataStore.swift new file mode 100644 index 0000000..34a4a50 --- /dev/null +++ b/MarksWidget/WidgetDataStore.swift @@ -0,0 +1,117 @@ +import Foundation +import OSLog +#if canImport(WidgetKit) +import WidgetKit +#endif + +private let log = Logger(subsystem: "com.magicive.marks", category: "WidgetDataStore") + +struct WidgetBookmark: Codable, Identifiable, Sendable { + var id: String { url } + let url: String + let title: String + let domain: String + let faviconUrl: String? +} + +struct WidgetPodcast: Codable, Identifiable, Sendable { + var id: String { articleUrl } + let articleUrl: String + let title: String + let createdAt: Date +} + +enum WidgetDataStore { + static let groupID = "group.com.magicive.marks" + + private static var containerURL: URL? { + let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) + if url == nil { log.error("App Group container not found — group ID: \(groupID)") } + return url + } + + private static func fileURL(_ name: String) -> URL? { + containerURL?.appendingPathComponent(name) + } + + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + private static let decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + // MARK: - Bookmarks + + static func saveBookmarks(_ bookmarks: [WidgetBookmark]) { + guard let url = fileURL("widget_bookmarks.json"), + let data = try? encoder.encode(bookmarks) else { return } + try? data.write(to: url, options: .atomic) + reloadTimelines() + } + + static func loadBookmarks() -> [WidgetBookmark] { + guard let url = fileURL("widget_bookmarks.json") else { + log.error("loadBookmarks: no container URL") + return [] + } + guard let data = try? Data(contentsOf: url) else { + log.warning("loadBookmarks: file not found at \(url.path)") + return [] + } + guard let items = try? decoder.decode([WidgetBookmark].self, from: data) else { + log.error("loadBookmarks: decode failed") + return [] + } + log.info("loadBookmarks: loaded \(items.count) bookmarks") + return items + } + + // MARK: - Podcasts + + static func savePodcasts(_ podcasts: [WidgetPodcast]) { + guard let url = fileURL("widget_podcasts.json"), + let data = try? encoder.encode(podcasts) else { return } + try? data.write(to: url, options: .atomic) + reloadTimelines() + } + + static func loadPodcasts() -> [WidgetPodcast] { + guard let url = fileURL("widget_podcasts.json"), + let data = try? Data(contentsOf: url), + let items = try? decoder.decode([WidgetPodcast].self, from: data) + else { return [] } + return items + } + + private static func reloadTimelines() { + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } +} + +// MARK: - Deep link URL helpers + +extension URL { + static func marksBookmark(_ url: String) -> URL { + var c = URLComponents() + c.scheme = "marks" + c.host = "bookmark" + c.queryItems = [URLQueryItem(name: "url", value: url)] + return c.url ?? URL(string: "marks://bookmark")! + } + + static func marksPodcast(_ url: String) -> URL { + var c = URLComponents() + c.scheme = "marks" + c.host = "podcast" + c.queryItems = [URLQueryItem(name: "url", value: url)] + return c.url ?? URL(string: "marks://podcast")! + } +} diff --git a/project.yml b/project.yml index ea063ef..d37a08d 100644 --- a/project.yml +++ b/project.yml @@ -21,6 +21,7 @@ schemes: targets: Marks: all ShareExtension: all + MarksWidget: all run: config: Debug test: @@ -32,12 +33,37 @@ schemes: archive: config: Release + MarksWidget: + build: + targets: + Marks: all + MarksWidget: all + run: + config: Debug + analyze: + config: Debug + archive: + config: Release + + ShareExtension: + build: + targets: + Marks: all + ShareExtension: all + run: + config: Debug + analyze: + config: Debug + archive: + config: Release + targets: Marks: type: application platform: iOS sources: - path: Marks + - path: MarksWidget/WidgetDataStore.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks @@ -46,6 +72,8 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon dependencies: - target: ShareExtension + - target: MarksWidget + - sdk: WidgetKit.framework ShareExtension: type: app-extension @@ -59,3 +87,18 @@ targets: CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements SKIP_INSTALL: YES APPLICATION_EXTENSION_API_ONLY: YES + + MarksWidget: + type: app-extension + platform: iOS + sources: + - path: MarksWidget + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksWidget + INFOPLIST_FILE: MarksWidget/Info.plist + CODE_SIGN_ENTITLEMENTS: MarksWidget/MarksWidget.entitlements + SKIP_INSTALL: YES + dependencies: + - sdk: WidgetKit.framework + - sdk: SwiftUI.framework