Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s
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>
This commit is contained in:
@@ -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 */;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
105
Marks.xcodeproj/xcshareddata/xcschemes/MarksWidget.xcscheme
Normal file
105
Marks.xcodeproj/xcshareddata/xcschemes/MarksWidget.xcscheme
Normal 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>
|
||||||
110
Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
Normal file
110
Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
Marks/Views/BookmarkListRow.swift
Normal file
111
Marks/Views/BookmarkListRow.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,57 @@
|
|||||||
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
|
||||||
@@ -17,84 +64,23 @@ struct BookmarksView: View {
|
|||||||
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: 16, bottom: 0, trailing: 16))
|
}
|
||||||
.listRowSeparator(.visible)
|
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task { await viewModel.delete(bookmark) }
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.swipeActions(edge: .leading) {
|
|
||||||
Button {
|
|
||||||
Task { await viewModel.archive(bookmark) }
|
|
||||||
} label: {
|
|
||||||
Label("Archive", systemImage: "archivebox")
|
|
||||||
}
|
|
||||||
.tint(.orange)
|
|
||||||
Button {
|
|
||||||
editingBookmark = bookmark
|
|
||||||
} label: {
|
|
||||||
Label("Edit", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
.tint(.blue)
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button {
|
|
||||||
editingBookmark = bookmark
|
|
||||||
} label: {
|
|
||||||
Label("Edit", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
browsingBookmark = bookmark
|
|
||||||
} label: {
|
|
||||||
Label("Open", systemImage: "globe")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
|
||||||
} label: {
|
|
||||||
Label("Open in Safari", systemImage: "safari")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
viewModel.podcastPlayer.start(
|
|
||||||
articleUrl: bookmark.url,
|
|
||||||
articleTitle: bookmark.displayTitle,
|
|
||||||
claude: viewModel.claude
|
|
||||||
)
|
|
||||||
showFullPlayer = true
|
|
||||||
} label: {
|
|
||||||
Label("Convert to Podcast", systemImage: "headphones")
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
Button {
|
|
||||||
Task { await viewModel.archive(bookmark) }
|
|
||||||
} label: {
|
|
||||||
Label("Archive", systemImage: "archivebox")
|
|
||||||
}
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task { await viewModel.delete(bookmark) }
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoadingMore {
|
if viewModel.isLoadingMore {
|
||||||
@@ -103,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 {
|
||||||
@@ -139,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")
|
||||||
@@ -147,11 +135,13 @@ 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) {
|
||||||
@@ -169,6 +159,7 @@ struct BookmarksView: View {
|
|||||||
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
|
.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)
|
||||||
@@ -184,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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ final class PodcastPlayerViewModel {
|
|||||||
private var interruptionObserver: 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 if already generating or playing this article
|
// Idempotent — don't restart if already generating or playing this article
|
||||||
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
||||||
|
|
||||||
@@ -90,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
|
||||||
}
|
}
|
||||||
@@ -674,3 +674,72 @@ struct PodcastLibraryView: View {
|
|||||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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
25
MarksWidget/Info.plist
Normal 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>
|
||||||
10
MarksWidget/MarksWidget.entitlements
Normal file
10
MarksWidget/MarksWidget.entitlements
Normal 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>
|
||||||
11
MarksWidget/MarksWidgetBundle.swift
Normal file
11
MarksWidget/MarksWidgetBundle.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MarksWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
RecentBookmarksWidget()
|
||||||
|
RandomBookmarkWidget()
|
||||||
|
RecentPodcastsWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
98
MarksWidget/RandomBookmarkWidget.swift
Normal file
98
MarksWidget/RandomBookmarkWidget.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
108
MarksWidget/RecentBookmarksWidget.swift
Normal file
108
MarksWidget/RecentBookmarksWidget.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
111
MarksWidget/RecentPodcastsWidget.swift
Normal file
111
MarksWidget/RecentPodcastsWidget.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
117
MarksWidget/WidgetDataStore.swift
Normal file
117
MarksWidget/WidgetDataStore.swift
Normal 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")!
|
||||||
|
}
|
||||||
|
}
|
||||||
43
project.yml
43
project.yml
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user