Compare commits

...

20 Commits

Author SHA1 Message Date
Krishna Kumar
199fc2d043 Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s
- 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 <noreply@anthropic.com>
2026-05-24 13:11:05 -05:00
Krishna Kumar
d87500a7dc experiment(BookmarksView): row horizontal insets 20pt → 16pt for tighter editorial margins 2026-05-23 01:14:18 -05:00
Krishna Kumar
3de93ac61d experiment(BookmarkRow): podcast icon 26pt → 20pt, less visual noise in list 2026-05-23 01:13:15 -05:00
Krishna Kumar
ee9a7c018e experiment(BookmarkRow): show dateAdded relative time inline with domain — 'domain · 2 days ago' 2026-05-23 01:12:48 -05:00
Krishna Kumar
89bcecebab experiment(BookmarkRow): AI summary 15pt → 14pt italic for editorial reading-app feel 2026-05-23 01:12:23 -05:00
Krishna Kumar
baadaf77a3 experiment(BookmarkRow): favicon 26×26 → 32×32, corner radius 5→7, align offsets to 44 2026-05-23 01:11:54 -05:00
Krishna Kumar
20f06c8440 experiment(BookmarkRow): tag pills blue fill → neutral systemGray6, secondary text 2026-05-23 01:11:03 -05:00
Krishna Kumar
18f1564d3b experiment(BookmarkRow): domain font 16pt → 13pt, tighter metadata line 2026-05-23 01:10:45 -05:00
Krishna Kumar
7c5ffbf69b experiment(BookmarkRow): title font 22pt medium → 17pt semibold for reading app scale 2026-05-23 01:10:24 -05:00
Krishna Kumar
15e2801b87 chore: gitignore autoresearch-results.tsv 2026-05-23 01:10:05 -05:00
Krishna Kumar
5c52693d4d experiment(PodcastPlayerView): add ShareLink button to full player for sharing episode URL 2026-05-23 00:56:48 -05:00
Krishna Kumar
2a0cb2d648 experiment(PodcastPlayerView): add MPMediaItemArtwork to Now Playing — waveform rendered image 2026-05-23 00:55:54 -05:00
Krishna Kumar
f61179e568 experiment(PodcastPlayerView): add swipe-to-delete in PodcastLibraryView, stops player if active 2026-05-23 00:55:04 -05:00
Krishna Kumar
09430e8e66 experiment(PodcastPlayerView): handle AVAudioSession interruptions — pause on call, resume after 2026-05-23 00:54:12 -05:00
Krishna Kumar
140c761850 experiment(PodcastPlayerView): add Retry button to failedView, clear pollingTask on failure 2026-05-23 00:52:55 -05:00
Krishna Kumar
f7d5c129fc experiment(PodcastPlayerView): add playbackSpeed control (0.75×/1×/1.25×/1.5×/2×) with menu 2026-05-23 00:51:23 -05:00
Krishna Kumar
be4a4c3a5f experiment(PodcastPlayerView): add lock screen skipBackwardCommand/skipForwardCommand (15s) 2026-05-23 00:50:07 -05:00
Krishna Kumar
fc0f98f45a experiment(PodcastPlayerView): add skip ±15s buttons to full player controls 2026-05-23 00:48:46 -05:00
Krishna Kumar
ea8a425c21 experiment(BookmarkRow): add sensoryFeedback haptic on podcast trigger button 2026-05-23 00:47:40 -05:00
Krishna Kumar
ab72611d7b Podcast UX baseline: mini player during generation, end-of-track reset, library view
- Mini player shows as soon as generation starts (not just when audio ready)
- Idempotency: tapping headphones again during generation is a no-op
- Playback resets to position 0 on completion (standard player behavior)
- Stop button in full player visible during generation
- PodcastLibraryView lists all generated podcasts
- Sparkles menu gains Podcasts entry
- Fix AnalyticsService Sendable error (serialize before Task.detached boundary)
2026-05-23 00:46:46 -05:00
26 changed files with 1741 additions and 214 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ Pods/
# VS Code # VS Code
.vscode/ .vscode/
autoresearch-results.tsv

View File

@@ -7,8 +7,8 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 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 */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.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 */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.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 */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.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 */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; 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 */; }; 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 */; }; 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.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 */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.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, ); }; }; 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 */; }; 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 */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.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 */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
proxyType = 1;
remoteGlobalIDString = 61046BF39C48393DD96687A0;
remoteInfo = MarksWidget;
};
BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = { BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */; containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
@@ -51,6 +69,7 @@
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */, B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -59,9 +78,17 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; }; 03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
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 = "<group>"; }; 07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = "<group>"; };
55D369E6D2B8B036685BBD13 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
@@ -71,6 +98,8 @@
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; };
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; }; CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* 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 */ /* Begin PBXGroup section */
3952A081972B444674BEB51B /* Marks */ = { 3952A081972B444674BEB51B /* Marks */ = {
isa = PBXGroup; isa = PBXGroup;
@@ -127,8 +177,8 @@
7ABBFDF43A1EB773E0A49CFE /* Services */ = { 7ABBFDF43A1EB773E0A49CFE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */, 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
@@ -136,10 +186,25 @@
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
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 = "<group>";
};
AF9CABF75E77168EF1D60F29 /* Views */ = { AF9CABF75E77168EF1D60F29 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
@@ -159,7 +224,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3952A081972B444674BEB51B /* Marks */, 3952A081972B444674BEB51B /* Marks */,
85E717A515682EBF67DD199A /* MarksWidget */,
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */, 7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
F5B3C85424137FA0CE2467B1 /* Frameworks */,
C6978BB2E0A71E9A349D5EB6 /* Products */, C6978BB2E0A71E9A349D5EB6 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -168,26 +235,56 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AB6C53AB14A38FCD4CC7628D /* Marks.app */, AB6C53AB14A38FCD4CC7628D /* Marks.app */,
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */,
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */, 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
isa = PBXGroup;
children = (
093402014727A338745A06DA /* SwiftUI.framework */,
0552C13335034219DECF4F62 /* WidgetKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget 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 */ = { 76C272776D8EBCCD3FB6715A /* Marks */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */; buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */;
buildPhases = ( buildPhases = (
81AF93AC59791A6311B9055B /* Sources */, 81AF93AC59791A6311B9055B /* Sources */,
D56AC7DEA48961F856A6CEF6 /* Resources */, D56AC7DEA48961F856A6CEF6 /* Resources */,
6B2547BA0E17C2564677B34A /* Frameworks */,
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */, 93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */, A87C510E52F149E7E3CA0283 /* PBXTargetDependency */,
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */,
); );
name = Marks; name = Marks;
packageProductDependencies = ( packageProductDependencies = (
@@ -222,6 +319,10 @@
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1630; LastUpgradeCheck = 1630;
TargetAttributes = { TargetAttributes = {
61046BF39C48393DD96687A0 = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
};
76C272776D8EBCCD3FB6715A = { 76C272776D8EBCCD3FB6715A = {
DevelopmentTeam = AE5DZKJHGN; DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
@@ -247,6 +348,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
76C272776D8EBCCD3FB6715A /* Marks */, 76C272776D8EBCCD3FB6715A /* Marks */,
61046BF39C48393DD96687A0 /* MarksWidget */,
CF31B2655F8F30CF6DF3C26F /* ShareExtension */, CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
); );
}; };
@@ -272,18 +374,31 @@
); );
runOnlyForDeploymentPostprocessing = 0; 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 */ = { 81AF93AC59791A6311B9055B /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
706CCE40744D7F382AFFE429 /* BrowserView.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 */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
@@ -297,12 +412,18 @@
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */, 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 61046BF39C48393DD96687A0 /* MarksWidget */;
targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */;
};
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = { A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */; target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
@@ -422,6 +543,23 @@
}; };
name = Release; 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 */ = { 7AD80830605CAD3492E03039 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -506,6 +644,23 @@
}; };
name = Debug; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -536,6 +691,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6CDF13C33FDD1141CFE677AD /* Debug */,
D8AB62E7173CC10C6EB15373 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */; rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;

View File

@@ -21,6 +21,20 @@
ReferencedContainer = "container:Marks.xcodeproj"> ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
buildForRunning = "YES" buildForRunning = "YES"

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -28,5 +28,14 @@
<array> <array>
<string>audio</string> <string>audio</string>
</array> </array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>marks</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -18,16 +18,24 @@ struct MarksApp: App {
defaultConfig.save() defaultConfig.save()
serverConfig = defaultConfig serverConfig = defaultConfig
} }
.task { serverConfig.save() }
} }
} }
} }
private struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
// Holds the API + ViewModel so they survive re-renders // Holds the API + ViewModel so they survive re-renders
struct MainContainer: View { struct MainContainer: View {
let config: ServerConfig let config: ServerConfig
let onDisconnect: () -> Void let onDisconnect: () -> Void
@State private var viewModel: BookmarksViewModel @State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL?
@State private var showDeepLinkPlayer = false
init(config: ServerConfig, onDisconnect: @escaping () -> Void) { init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config self.config = config
@@ -48,5 +56,45 @@ struct MainContainer: View {
SearchView(viewModel: viewModel) 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
}
} }
} }

View File

@@ -2,6 +2,10 @@ import Foundation
enum Analytics { enum Analytics {
static func track(_ event: String, _ properties: [String: Any] = [:]) { static func track(_ event: String, _ properties: [String: Any] = [:]) {
// Serialize before crossing the task boundary Data is Sendable, [String: Any] is not
var body: [String: Any] = ["event": event]
if !properties.isEmpty { body["properties"] = properties }
guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return }
Task.detached { Task.detached {
do { do {
let token = try await MarksAuth.validToken() let token = try await MarksAuth.validToken()
@@ -10,9 +14,7 @@ enum Analytics {
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["event": event] request.httpBody = payload
if !properties.isEmpty { body["properties"] = properties }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status < 200 || status > 299 { if status < 200 || status > 299 {

View File

@@ -67,13 +67,13 @@ struct ClaudeService: Sendable {
return try JSONDecoder().decode(PodcastStatus.self, from: data) 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) let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(), try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
let data = try await get("/v1/podcast/audio/\(jobId)") let data = try await get("/v1/podcast/audio/\(jobId)")
try data.write(to: dest, options: .atomic) 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 return dest
} }

View File

@@ -6,6 +6,7 @@ struct PodcastEntry: Codable, Identifiable {
let filename: String let filename: String
var title: String? var title: String?
let createdAt: Date let createdAt: Date
var parentBookmarkUrl: String?
} }
enum PodcastIndex { enum PodcastIndex {
@@ -35,14 +36,19 @@ enum PodcastIndex {
return entries.sorted { $0.createdAt > $1.createdAt } 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() var entries = all()
entries.removeAll { $0.articleUrl == articleUrl } entries.removeAll { $0.articleUrl == articleUrl }
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename, entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
title: title, createdAt: Date()), at: 0) title: title, createdAt: Date(),
parentBookmarkUrl: parentBookmarkUrl), at: 0)
save(entries) save(entries)
} }
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
}
static func remove(articleUrl: String) { static func remove(articleUrl: String) {
var entries = all() var entries = all()
entries.removeAll { $0.articleUrl == articleUrl } entries.removeAll { $0.articleUrl == articleUrl }
@@ -52,5 +58,8 @@ enum PodcastIndex {
private static func save(_ entries: [PodcastEntry]) { private static func save(_ entries: [PodcastEntry]) {
guard let data = try? encoder.encode(entries) else { return } guard let data = try? encoder.encode(entries) else { return }
try? data.write(to: indexURL, options: .atomic) 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)
})
} }
} }

View File

@@ -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
}
}
}

View File

@@ -5,6 +5,8 @@ struct BookmarkRow: View {
var readingProgress: Double = 0 var readingProgress: Double = 0
var onPodcast: (() -> Void)? = nil var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 0
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 11) { HStack(alignment: .top, spacing: 11) {
@@ -21,35 +23,44 @@ struct BookmarkRow: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(bookmark.displayTitle) Text(bookmark.displayTitle)
.font(.system(size: 22, weight: .medium)) .font(.system(size: 17, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.lineLimit(2) .lineLimit(2)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text(bookmark.domain) HStack(spacing: 4) {
.font(.system(size: 16)) Text(bookmark.domain)
.foregroundStyle(.tertiary) Text("·")
Text(bookmark.dateAdded, style: .relative)
}
.font(.system(size: 13))
.foregroundStyle(.tertiary)
} }
Spacer() Spacer()
if let onPodcast { if let onPodcast {
Button(action: onPodcast) { Button {
podcastTapCount += 1
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 26)) .font(.system(size: 20))
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3)) .foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.top, 1) .padding(.top, 1)
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
} }
} }
if let summary = bookmark.aiSummary { if let summary = bookmark.aiSummary {
Text(summary) Text(summary)
.font(.system(size: 15)) .font(.system(size: 14))
.italic()
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
.padding(.leading, 40) .padding(.leading, 44)
} }
if !effectiveTags.isEmpty { if !effectiveTags.isEmpty {
@@ -57,16 +68,16 @@ struct BookmarkRow: View {
HStack(spacing: 6) { HStack(spacing: 6) {
ForEach(effectiveTags, id: \.self) { tag in ForEach(effectiveTags, id: \.self) { tag in
Text(tag) Text(tag)
.font(.system(size: 12, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(.blue) .foregroundStyle(.secondary)
.padding(.horizontal, 9) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.vertical, 3)
.background(Color.blue.opacity(0.1)) .background(Color(.systemGray6))
.clipShape(Capsule()) .clipShape(Capsule())
} }
} }
} }
.padding(.leading, 40) .padding(.leading, 44)
} }
// Reading progress indicator // Reading progress indicator
@@ -82,7 +93,7 @@ struct BookmarkRow: View {
} }
.frame(height: 2) .frame(height: 2)
.clipShape(Capsule()) .clipShape(Capsule())
.padding(.leading, 40) .padding(.leading, 44)
} }
} }
.padding(.vertical, 14) .padding(.vertical, 14)
@@ -119,16 +130,16 @@ struct FaviconView: View {
placeholder placeholder
} }
} }
.frame(width: 26, height: 26) .frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 5)) .clipShape(RoundedRectangle(cornerRadius: 7))
} }
private var placeholder: some View { private var placeholder: some View {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 6)
.fill(Color(.systemGray5)) .fill(Color(.systemGray5))
.overlay { .overlay {
Image(systemName: "bookmark") Image(systemName: "bookmark")
.font(.system(size: 9, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(.systemGray2)) .foregroundStyle(Color(.systemGray2))
} }
} }

View File

@@ -1,99 +1,86 @@
import SwiftUI 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 { struct BookmarksView: View {
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void let onDisconnect: () -> Void
@Environment(\.openURL) private var openURL
@State private var showSettings = false @State private var showSettings = false
@State private var showCollections = false @State private var showCollections = false
@State private var showAddBookmark = false @State private var showAddBookmark = false
@State private var editingBookmark: Bookmark? @State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark? @State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false @State private var showFullPlayer = false
@State private var showPodcastLibrary = false
@State private var readingProgress: [String: Double] = ReadingProgress.all() @State private var readingProgress: [String: Double] = ReadingProgress.all()
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
ForEach(viewModel.bookmarks) { bookmark in if viewModel.isLoading && viewModel.bookmarks.isEmpty {
BookmarkRow( ForEach(0..<3, id: \.self) { i in
bookmark: bookmark, SkeletonRow(delay: Double(i) * 0.13)
readingProgress: readingProgress[bookmark.url] ?? 0, .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
onPodcast: { .listRowSeparator(.visible)
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
}
)
.onTapGesture {
browsingBookmark = bookmark
} }
} 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) } .onAppear { maybeLoadMore(bookmark) }
.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)
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 { if viewModel.isLoadingMore {
@@ -102,6 +89,7 @@ struct BookmarksView: View {
} }
} }
.listStyle(.plain) .listStyle(.plain)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.toolbar { .toolbar {
@@ -118,6 +106,11 @@ struct BookmarksView: View {
} label: { } label: {
Label("Enrich All with AI", systemImage: "wand.and.stars") Label("Enrich All with AI", systemImage: "wand.and.stars")
} }
Button {
showPodcastLibrary = true
} label: {
Label("Podcasts", systemImage: "headphones")
}
} label: { } label: {
Image(systemName: "sparkles") Image(systemName: "sparkles")
} }
@@ -133,6 +126,7 @@ struct BookmarksView: View {
Image(systemName: viewModel.unreadFilter Image(systemName: viewModel.unreadFilter
? "line.3.horizontal.decrease.circle.fill" ? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle") : "line.3.horizontal.decrease.circle")
.contentTransition(.symbolEffect(.replace))
} }
Button { showSettings = true } label: { Button { showSettings = true } label: {
Image(systemName: "gearshape") Image(systemName: "gearshape")
@@ -141,16 +135,18 @@ struct BookmarksView: View {
} }
} }
.overlay { .overlay {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ProgressView()
}
if !viewModel.isLoading && viewModel.bookmarks.isEmpty { 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) { .overlay(alignment: .bottom) {
VStack(spacing: 8) { VStack(spacing: 8) {
if viewModel.podcastPlayer.player != nil { if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) { MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true showFullPlayer = true
} }
@@ -160,9 +156,10 @@ struct BookmarksView: View {
enrichmentBanner enrichmentBanner
} }
} }
.animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil) .animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.padding(.bottom, 8) .padding(.bottom, 8)
} }
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
} }
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect) SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
@@ -178,7 +175,9 @@ struct BookmarksView: View {
} }
.sheet(item: $browsingBookmark) { bookmark in .sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) { 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 .onChange(of: browsingBookmark) { _, new in
@@ -193,6 +192,9 @@ struct BookmarksView: View {
stopOnDismiss: false stopOnDismiss: false
) )
} }
.sheet(isPresented: $showPodcastLibrary) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
.refreshable { .refreshable {
await viewModel.load() await viewModel.load()
} }

View File

@@ -39,6 +39,9 @@ final class BookmarksViewModel {
nextPageUrl = response.next nextPageUrl = response.next
restoreAIData() restoreAIData()
saveToCache(bookmarks) saveToCache(bookmarks)
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
})
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }

View File

@@ -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 // MARK: - BrowserState
@Observable @Observable
@@ -196,18 +219,25 @@ struct BrowserView: View {
let initialURL: URL let initialURL: URL
let initialTitle: String let initialTitle: String
let claude: ClaudeService let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel
var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@State private var state: BrowserState @State private var state: BrowserState
@State private var readerMode = false @State private var readerMode = false
@State private var showPodcast = 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.initialURL = url
self.initialTitle = title self.initialTitle = title
self.claude = claude self.claude = claude
self.podcastPlayer = podcastPlayer
self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url)) self._state = State(initialValue: BrowserState(url: url))
} }
@@ -221,16 +251,17 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 { if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in GeometryReader { geo in
Rectangle() Rectangle()
.fill(Color.blue.opacity(0.7)) .fill(Color.blue.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3) .frame(width: geo.size.width * state.readingProgress, height: 3)
} }
.frame(height: 3) .frame(height: 3)
.animation(.linear(duration: 0.1), value: state.readingProgress) .animation(.linear(duration: 0.1), value: state.readingProgress)
} }
// Page loading sweep bar (replaces center spinner)
if state.isLoading { if state.isLoading {
ProgressView() PageLoadBar()
.padding(.top, 8) .transition(.opacity)
} }
} }
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle) .navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
@@ -247,44 +278,28 @@ struct BrowserView: View {
Image(systemName: "safari") Image(systemName: "safari")
} }
} }
ToolbarItemGroup(placement: .bottomBar) { }
Button { state.webView.goBack() } label: { }
Image(systemName: "chevron.left") .safeAreaInset(edge: .bottom, spacing: 0) {
} bottomBar
.disabled(!state.canGoBack) .offset(y: toolbarHidden ? 88 : 0)
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
Spacer() }
.onChange(of: state.readingProgress) { old, new in
Button { state.webView.goForward() } label: { let delta = new - old
Image(systemName: "chevron.right") withAnimation(.spring(duration: 0.3, bounce: 0)) {
} if delta > 0.012 && new > 0.07 {
.disabled(!state.canGoForward) toolbarHidden = true
} else if delta < -0.005 || new < 0.05 {
Spacer() toolbarHidden = false
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")
}
} }
} }
} }
.onChange(of: state.isLoading) { _, loading in
if loading {
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
}
}
.onDisappear { .onDisappear {
let urlStr = (state.currentURL ?? initialURL).absoluteString let urlStr = (state.currentURL ?? initialURL).absoluteString
let p = state.readingProgress let p = state.readingProgress
@@ -292,12 +307,101 @@ struct BrowserView: View {
} }
.sheet(isPresented: $showPodcast) { .sheet(isPresented: $showPodcast) {
PodcastPlayerView( PodcastPlayerView(
vm: podcastVM, vm: podcastPlayer,
articleUrl: (state.currentURL ?? initialURL).absoluteString, articleUrl: (state.currentURL ?? initialURL).absoluteString,
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle, articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
claude: claude, 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() }
}
} }

View File

@@ -42,14 +42,26 @@ final class PodcastPlayerViewModel {
var duration: Double = 1 var duration: Double = 1
var currentArticleUrl: String = "" var currentArticleUrl: String = ""
var currentArticleTitle: String = "" var currentArticleTitle: String = ""
var playbackSpeed: Float = 1.0
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
var isGenerating: Bool {
switch phase {
case .generating, .downloading: return true
default: return false
}
}
private var pollingTask: Task<Void, Never>? private var pollingTask: Task<Void, Never>?
private var timeObserver: Any? private var timeObserver: Any?
private var endObserver: Any?
private var interruptionObserver: Any?
private var currentPodcastTitle: String = "Marks Podcast" 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 the same article if already playing // Idempotent don't restart if already generating or playing this article
if currentArticleUrl == articleUrl && player != nil { return } if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
// Teardown previous episode (saves its position) // Teardown previous episode (saves its position)
if player != nil { stop() } if player != nil { stop() }
@@ -78,7 +90,7 @@ final class PodcastPlayerViewModel {
) )
if job.isDone { if job.isDone {
phase = .downloading 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") setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return return
} }
@@ -86,6 +98,7 @@ final class PodcastPlayerViewModel {
let errMsg = job.error ?? "Generation failed" let errMsg = job.error ?? "Generation failed"
Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg]) Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg])
phase = .failed(errMsg) phase = .failed(errMsg)
pollingTask = nil
return return
} }
try await Task.sleep(for: .seconds(3)) try await Task.sleep(for: .seconds(3))
@@ -94,6 +107,7 @@ final class PodcastPlayerViewModel {
} catch { } catch {
Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription]) Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription])
phase = .failed(error.localizedDescription) phase = .failed(error.localizedDescription)
pollingTask = nil
} }
} }
} }
@@ -105,6 +119,10 @@ final class PodcastPlayerViewModel {
pollingTask?.cancel() pollingTask?.cancel()
player?.pause() player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) } if let obs = timeObserver { player?.removeTimeObserver(obs) }
if let obs = endObserver { NotificationCenter.default.removeObserver(obs) }
endObserver = nil
if let obs = interruptionObserver { NotificationCenter.default.removeObserver(obs) }
interruptionObserver = nil
player = nil player = nil
isPlaying = false isPlaying = false
currentTime = 0 currentTime = 0
@@ -118,12 +136,18 @@ final class PodcastPlayerViewModel {
c.playCommand.removeTarget(nil) c.playCommand.removeTarget(nil)
c.pauseCommand.removeTarget(nil) c.pauseCommand.removeTarget(nil)
c.changePlaybackPositionCommand.removeTarget(nil) c.changePlaybackPositionCommand.removeTarget(nil)
c.skipBackwardCommand.removeTarget(nil)
c.skipForwardCommand.removeTarget(nil)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
} }
func togglePlayPause() { func togglePlayPause() {
guard let player else { return } guard let player else { return }
if isPlaying { player.pause() } else { player.play() } if isPlaying {
player.pause()
} else {
player.rate = playbackSpeed
}
isPlaying.toggle() isPlaying.toggle()
updateNowPlaying() updateNowPlaying()
} }
@@ -134,6 +158,20 @@ final class PodcastPlayerViewModel {
updateNowPlaying() updateNowPlaying()
} }
func skipForward15() {
seek(to: min(currentTime + 15, duration))
}
func skipBackward15() {
seek(to: max(currentTime - 15, 0))
}
func setPlaybackSpeed(_ speed: Float) {
playbackSpeed = speed
if isPlaying { player?.rate = speed }
updateNowPlaying()
}
private func setupPlayer(url: URL, title: String) { private func setupPlayer(url: URL, title: String) {
currentPodcastTitle = title currentPodcastTitle = title
@@ -158,6 +196,40 @@ final class PodcastPlayerViewModel {
} }
} }
// Reset to start when playback finishes, like standard media players
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in
guard let self else { return }
self.isPlaying = false
self.currentTime = 0
self.player?.seek(to: .zero)
PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration)
self.updateNowPlaying()
}
// Pause on phone calls / Siri; resume when interruption ends
interruptionObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
) { [weak self] notification in
guard let self,
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
if type == .began {
self.player?.pause()
self.isPlaying = false
self.updateNowPlaying()
} else if type == .ended {
let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
self.player?.rate = self.playbackSpeed
self.isPlaying = true
self.updateNowPlaying()
}
}
}
let interval = CMTime(seconds: 0.5, preferredTimescale: 600) let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
guard let self else { return } guard let self else { return }
@@ -173,7 +245,7 @@ final class PodcastPlayerViewModel {
setupRemoteCommands() setupRemoteCommands()
phase = .ready(title: title) phase = .ready(title: title)
updateNowPlaying() updateNowPlaying()
p.play() p.rate = playbackSpeed
isPlaying = true isPlaying = true
} }
@@ -194,18 +266,48 @@ final class PodcastPlayerViewModel {
self?.currentTime = e.positionTime self?.currentTime = e.positionTime
return .success return .success
} }
c.skipBackwardCommand.isEnabled = true
c.skipBackwardCommand.preferredIntervals = [15]
c.skipBackwardCommand.addTarget { [weak self] _ in
self?.skipBackward15(); return .success
}
c.skipForwardCommand.isEnabled = true
c.skipForwardCommand.preferredIntervals = [15]
c.skipForwardCommand.addTarget { [weak self] _ in
self?.skipForward15(); return .success
}
} }
private func updateNowPlaying() { private func updateNowPlaying() {
var info: [String: Any] = [:] var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentPodcastTitle info[MPMediaItemPropertyTitle] = currentPodcastTitle
info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? Double(playbackSpeed) : 0.0
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration } if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
if let artwork = Self.nowPlayingArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info MPNowPlayingInfoCenter.default().nowPlayingInfo = info
} }
private static let nowPlayingArtwork: MPMediaItemArtwork? = {
let size = CGSize(width: 300, height: 300)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { ctx in
UIColor.systemBlue.withAlphaComponent(0.15).setFill()
ctx.fill(CGRect(origin: .zero, size: size))
let config = UIImage.SymbolConfiguration(pointSize: 140, weight: .regular)
if let symbol = UIImage(systemName: "waveform.circle.fill", withConfiguration: config) {
let tinted = symbol.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
let origin = CGPoint(x: (size.width - tinted.size.width) / 2,
y: (size.height - tinted.size.height) / 2)
tinted.draw(at: origin)
}
}
return MPMediaItemArtwork(boundsSize: size) { _ in image }
}()
private static func statusLabel(_ status: String) -> String { private static func statusLabel(_ status: String) -> String {
switch status { switch status {
case "queued": return "Queued…" case "queued": return "Queued…"
@@ -243,7 +345,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() } Button("Done") { dismiss() }
} }
if vm.player != nil { if !vm.currentArticleUrl.isEmpty {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: { Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle") Image(systemName: "stop.circle")
@@ -325,10 +427,53 @@ struct PodcastPlayerView: View {
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
Button { vm.togglePlayPause() } label: { HStack(spacing: 36) {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") Button { vm.skipBackward15() } label: {
.font(.system(size: 72)) Image(systemName: "gobackward.15")
.foregroundStyle(.primary) .font(.system(size: 32))
.foregroundStyle(.primary)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
}
Button { vm.skipForward15() } label: {
Image(systemName: "goforward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
}
Menu {
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
Button {
vm.setPlaybackSpeed(speed)
} label: {
let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×"
if speed == vm.playbackSpeed {
Label(label, systemImage: "checkmark")
} else {
Text(label)
}
}
}
} label: {
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color(.systemGray5))
.clipShape(Capsule())
}
if let url = URL(string: articleUrl) {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
}
} }
} }
} }
@@ -344,6 +489,18 @@ struct PodcastPlayerView: View {
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color.blue)
.clipShape(Capsule())
}
.padding(.top, 4)
} }
} }
@@ -365,7 +522,7 @@ struct MiniPlayerView: View {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue) .foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying) .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22) .frame(width: 22)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -380,15 +537,20 @@ struct MiniPlayerView: View {
Spacer() Spacer()
Button { if vm.isGenerating {
vm.togglePlayPause() ProgressView()
} label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.primary)
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} else {
Button {
vm.togglePlayPause()
} label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.primary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
} }
.buttonStyle(.plain)
Button { Button {
vm.stop() vm.stop()
@@ -435,3 +597,149 @@ struct MiniPlayerView: View {
return mins > 0 ? "\(mins) min left" : "Almost done" return mins > 0 ? "\(mins) min left" : "Almost done"
} }
} }
// MARK: - Podcast library
struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@State private var entries: [PodcastEntry] = []
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if entries.isEmpty {
ContentUnavailableView(
"No Podcasts",
systemImage: "headphones",
description: Text("Podcasts you generate will appear here.")
)
} else {
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)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Button {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? "",
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")
}
}
}
.onDelete { indexSet in
indexSet.forEach { deleteEntry(entries[$0]) }
}
}
.listStyle(.plain)
}
}
.navigationTitle("Podcasts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.onAppear { entries = PodcastIndex.all() }
}
private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
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() }
}
}

View File

@@ -2,30 +2,22 @@ import SwiftUI
struct SearchView: View { struct SearchView: View {
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var searchText = "" @State private var searchText = ""
@State private var useSemanticSearch = false @State private var useSemanticSearch = false
@State private var semanticResults: [Bookmark]? = nil @State private var semanticResults: [Bookmark]? = nil
@State private var searchTask: Task<Void, Never>? @State private var searchTask: Task<Void, Never>?
@State private var browsingBookmark: Bookmark?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
ForEach(results) { bookmark in ForEach(results) { bookmark in
BookmarkRow(bookmark: bookmark) BookmarkListRow(
.onTapGesture { bookmark: bookmark,
if let url = URL(string: bookmark.url) { openURL(url) } viewModel: viewModel,
} onOpen: { browsingBookmark = bookmark }
.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")
}
}
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -49,6 +41,19 @@ struct SearchView: View {
ProgressView() 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…") .searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.onChange(of: searchText) { _, _ in scheduleSearch() } .onChange(of: searchText) { _, _ in scheduleSearch() }

View File

@@ -48,32 +48,17 @@ struct TagsView: View {
struct TagBookmarksView: View { struct TagBookmarksView: View {
let tag: String let tag: String
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var browsingBookmark: Bookmark?
var body: some View { var body: some View {
List { List {
ForEach(filteredBookmarks) { bookmark in ForEach(filteredBookmarks) { bookmark in
BookmarkRow(bookmark: bookmark) BookmarkListRow(
.onTapGesture { bookmark: bookmark,
if let url = URL(string: bookmark.url) { openURL(url) } viewModel: viewModel,
} onOpen: { browsingBookmark = bookmark }
.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)
}
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -84,6 +69,19 @@ struct TagBookmarksView: View {
ContentUnavailableView("No Bookmarks", systemImage: "tag") 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] { private var filteredBookmarks: [Bookmark] {

25
MarksWidget/Info.plist Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>MarksWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.magicive.marks</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,11 @@
import WidgetKit
import SwiftUI
@main
struct MarksWidgetBundle: WidgetBundle {
var body: some Widget {
RecentBookmarksWidget()
RandomBookmarkWidget()
RecentPodcastsWidget()
}
}

View File

@@ -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<RandomBookmarkEntry>) -> 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])
}
}

View File

@@ -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<RecentBookmarksEntry>) -> 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])
}
}

View File

@@ -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<RecentPodcastsEntry>) -> 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])
}
}

View File

@@ -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")!
}
}

View File

@@ -21,6 +21,7 @@ schemes:
targets: targets:
Marks: all Marks: all
ShareExtension: all ShareExtension: all
MarksWidget: all
run: run:
config: Debug config: Debug
test: test:
@@ -32,12 +33,37 @@ schemes:
archive: archive:
config: Release 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: targets:
Marks: Marks:
type: application type: application
platform: iOS platform: iOS
sources: sources:
- path: Marks - path: Marks
- path: MarksWidget/WidgetDataStore.swift
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks
@@ -46,6 +72,8 @@ targets:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies: dependencies:
- target: ShareExtension - target: ShareExtension
- target: MarksWidget
- sdk: WidgetKit.framework
ShareExtension: ShareExtension:
type: app-extension type: app-extension
@@ -59,3 +87,18 @@ targets:
CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements
SKIP_INSTALL: YES SKIP_INSTALL: YES
APPLICATION_EXTENSION_API_ONLY: 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