Compare commits
20 Commits
18423a7633
...
199fc2d043
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199fc2d043 | ||
|
|
d87500a7dc | ||
|
|
3de93ac61d | ||
|
|
ee9a7c018e | ||
|
|
89bcecebab | ||
|
|
baadaf77a3 | ||
|
|
20f06c8440 | ||
|
|
18f1564d3b | ||
|
|
7c5ffbf69b | ||
|
|
15e2801b87 | ||
|
|
5c52693d4d | ||
|
|
2a0cb2d648 | ||
|
|
f61179e568 | ||
|
|
09430e8e66 | ||
|
|
140c761850 | ||
|
|
f7d5c129fc | ||
|
|
be4a4c3a5f | ||
|
|
fc0f98f45a | ||
|
|
ea8a425c21 | ||
|
|
ab72611d7b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ Pods/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
autoresearch-results.tsv
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
|
||||
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */; };
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||
@@ -16,24 +16,42 @@
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
||||
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
|
||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
|
||||
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
||||
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
|
||||
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
|
||||
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
|
||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
|
||||
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
|
||||
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
|
||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
|
||||
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; };
|
||||
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 61046BF39C48393DD96687A0;
|
||||
remoteInfo = MarksWidget;
|
||||
};
|
||||
BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||
@@ -51,6 +69,7 @@
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -59,9 +78,17 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -71,6 +98,8 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -80,15 +109,36 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
6B2547BA0E17C2564677B34A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A1E820C4B9604F8F05055D3E /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */,
|
||||
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
3952A081972B444674BEB51B /* Marks */ = {
|
||||
isa = PBXGroup;
|
||||
@@ -127,8 +177,8 @@
|
||||
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */,
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||
@@ -136,10 +186,25 @@
|
||||
path = Services;
|
||||
sourceTree = "<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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
||||
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
||||
@@ -159,7 +224,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3952A081972B444674BEB51B /* Marks */,
|
||||
85E717A515682EBF67DD199A /* MarksWidget */,
|
||||
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
|
||||
F5B3C85424137FA0CE2467B1 /* Frameworks */,
|
||||
C6978BB2E0A71E9A349D5EB6 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -168,26 +235,56 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB6C53AB14A38FCD4CC7628D /* Marks.app */,
|
||||
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */,
|
||||
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
093402014727A338745A06DA /* SwiftUI.framework */,
|
||||
0552C13335034219DECF4F62 /* WidgetKit.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
61046BF39C48393DD96687A0 /* MarksWidget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */;
|
||||
buildPhases = (
|
||||
2B3C202ACE4B7248E24601BF /* Sources */,
|
||||
A1E820C4B9604F8F05055D3E /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = MarksWidget;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = MarksWidget;
|
||||
productReference = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
76C272776D8EBCCD3FB6715A /* Marks */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */;
|
||||
buildPhases = (
|
||||
81AF93AC59791A6311B9055B /* Sources */,
|
||||
D56AC7DEA48961F856A6CEF6 /* Resources */,
|
||||
6B2547BA0E17C2564677B34A /* Frameworks */,
|
||||
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */,
|
||||
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Marks;
|
||||
packageProductDependencies = (
|
||||
@@ -222,6 +319,10 @@
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1630;
|
||||
TargetAttributes = {
|
||||
61046BF39C48393DD96687A0 = {
|
||||
DevelopmentTeam = AE5DZKJHGN;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
76C272776D8EBCCD3FB6715A = {
|
||||
DevelopmentTeam = AE5DZKJHGN;
|
||||
ProvisioningStyle = Automatic;
|
||||
@@ -247,6 +348,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
76C272776D8EBCCD3FB6715A /* Marks */,
|
||||
61046BF39C48393DD96687A0 /* MarksWidget */,
|
||||
CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
|
||||
);
|
||||
};
|
||||
@@ -272,18 +374,31 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
2B3C202ACE4B7248E24601BF /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */,
|
||||
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */,
|
||||
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */,
|
||||
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */,
|
||||
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
81AF93AC59791A6311B9055B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
|
||||
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */,
|
||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||
@@ -297,12 +412,18 @@
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
|
||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
|
||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 61046BF39C48393DD96687A0 /* MarksWidget */;
|
||||
targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */;
|
||||
};
|
||||
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
|
||||
@@ -422,6 +543,23 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6CDF13C33FDD1141CFE677AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
|
||||
INFOPLIST_FILE = MarksWidget/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7AD80830605CAD3492E03039 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -506,6 +644,23 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D8AB62E7173CC10C6EB15373 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
|
||||
INFOPLIST_FILE = MarksWidget/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -536,6 +691,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6CDF13C33FDD1141CFE677AD /* Debug */,
|
||||
D8AB62E7173CC10C6EB15373 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||
|
||||
@@ -21,6 +21,20 @@
|
||||
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>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "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>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>marks</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -18,16 +18,24 @@ struct MarksApp: App {
|
||||
defaultConfig.save()
|
||||
serverConfig = defaultConfig
|
||||
}
|
||||
.task { serverConfig.save() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IdentifiableURL: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
// Holds the API + ViewModel so they survive re-renders
|
||||
struct MainContainer: View {
|
||||
let config: ServerConfig
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
@State private var deepLinkBrowser: IdentifiableURL?
|
||||
@State private var showDeepLinkPlayer = false
|
||||
|
||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||
self.config = config
|
||||
@@ -48,5 +56,45 @@ struct MainContainer: View {
|
||||
SearchView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
.sheet(item: $deepLinkBrowser) { item in
|
||||
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
|
||||
}
|
||||
.sheet(isPresented: $showDeepLinkPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: viewModel.podcastPlayer,
|
||||
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
||||
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
||||
claude: viewModel.claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
guard url.scheme == "marks",
|
||||
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
|
||||
else { return }
|
||||
|
||||
switch url.host {
|
||||
case "bookmark":
|
||||
if let browserURL = URL(string: articleUrl) {
|
||||
deepLinkBrowser = IdentifiableURL(url: browserURL)
|
||||
}
|
||||
case "podcast":
|
||||
let podcasts = WidgetDataStore.loadPodcasts()
|
||||
let found = podcasts.first { $0.articleUrl == articleUrl }
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: articleUrl,
|
||||
articleTitle: found?.title ?? "",
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showDeepLinkPlayer = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import Foundation
|
||||
|
||||
enum Analytics {
|
||||
static func track(_ event: String, _ properties: [String: Any] = [:]) {
|
||||
// Serialize before crossing the task boundary — Data is Sendable, [String: Any] is not
|
||||
var body: [String: Any] = ["event": event]
|
||||
if !properties.isEmpty { body["properties"] = properties }
|
||||
guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let token = try await MarksAuth.validToken()
|
||||
@@ -10,9 +14,7 @@ enum Analytics {
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
var body: [String: Any] = ["event": event]
|
||||
if !properties.isEmpty { body["properties"] = properties }
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.httpBody = payload
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status < 200 || status > 299 {
|
||||
|
||||
@@ -67,13 +67,13 @@ struct ClaudeService: Sendable {
|
||||
return try JSONDecoder().decode(PodcastStatus.self, from: data)
|
||||
}
|
||||
|
||||
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil) async throws -> URL {
|
||||
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL {
|
||||
let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try await get("/v1/podcast/audio/\(jobId)")
|
||||
try data.write(to: dest, options: .atomic)
|
||||
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title)
|
||||
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return dest
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ struct PodcastEntry: Codable, Identifiable {
|
||||
let filename: String
|
||||
var title: String?
|
||||
let createdAt: Date
|
||||
var parentBookmarkUrl: String?
|
||||
}
|
||||
|
||||
enum PodcastIndex {
|
||||
@@ -35,14 +36,19 @@ enum PodcastIndex {
|
||||
return entries.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
static func upsert(articleUrl: String, filename: String, title: String?) {
|
||||
static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) {
|
||||
var entries = all()
|
||||
entries.removeAll { $0.articleUrl == articleUrl }
|
||||
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
|
||||
title: title, createdAt: Date()), at: 0)
|
||||
title: title, createdAt: Date(),
|
||||
parentBookmarkUrl: parentBookmarkUrl), at: 0)
|
||||
save(entries)
|
||||
}
|
||||
|
||||
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
|
||||
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
|
||||
}
|
||||
|
||||
static func remove(articleUrl: String) {
|
||||
var entries = all()
|
||||
entries.removeAll { $0.articleUrl == articleUrl }
|
||||
@@ -52,5 +58,8 @@ enum PodcastIndex {
|
||||
private static func save(_ entries: [PodcastEntry]) {
|
||||
guard let data = try? encoder.encode(entries) else { return }
|
||||
try? data.write(to: indexURL, options: .atomic)
|
||||
WidgetDataStore.savePodcasts(entries.prefix(10).map {
|
||||
WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ struct BookmarkRow: View {
|
||||
var readingProgress: Double = 0
|
||||
var onPodcast: (() -> Void)? = nil
|
||||
|
||||
@State private var podcastTapCount = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
@@ -21,35 +23,44 @@ struct BookmarkRow: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.displayTitle)
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(bookmark.domain)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.tertiary)
|
||||
HStack(spacing: 4) {
|
||||
Text(bookmark.domain)
|
||||
Text("·")
|
||||
Text(bookmark.dateAdded, style: .relative)
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let onPodcast {
|
||||
Button(action: onPodcast) {
|
||||
Button {
|
||||
podcastTapCount += 1
|
||||
onPodcast()
|
||||
} label: {
|
||||
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
|
||||
.font(.system(size: 26))
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 1)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
|
||||
}
|
||||
}
|
||||
|
||||
if let summary = bookmark.aiSummary {
|
||||
Text(summary)
|
||||
.font(.system(size: 15))
|
||||
.font(.system(size: 14))
|
||||
.italic()
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.padding(.leading, 40)
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
|
||||
if !effectiveTags.isEmpty {
|
||||
@@ -57,16 +68,16 @@ struct BookmarkRow: View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(effectiveTags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 40)
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
|
||||
// Reading progress indicator
|
||||
@@ -82,7 +93,7 @@ struct BookmarkRow: View {
|
||||
}
|
||||
.frame(height: 2)
|
||||
.clipShape(Capsule())
|
||||
.padding(.leading, 40)
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
@@ -119,16 +130,16 @@ struct FaviconView: View {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: 26, height: 26)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay {
|
||||
Image(systemName: "bookmark")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(.systemGray2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Skeleton loading row
|
||||
|
||||
private struct SkeletonRow: View {
|
||||
var delay: Double = 0
|
||||
@State private var dimmed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 32, height: 32)
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Capsule()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 13)
|
||||
Capsule()
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 140, height: 10)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.opacity(dimmed ? 0.45 : 1)
|
||||
.onAppear {
|
||||
withAnimation(
|
||||
.easeInOut(duration: 0.9)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(delay)
|
||||
) { dimmed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row press ButtonStyle
|
||||
|
||||
struct RowPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.opacity(configuration.isPressed ? 0.75 : 1)
|
||||
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookmarksView
|
||||
|
||||
struct BookmarksView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showSettings = false
|
||||
@State private var showCollections = false
|
||||
@State private var showAddBookmark = false
|
||||
@State private var editingBookmark: Bookmark?
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
@State private var showFullPlayer = false
|
||||
@State private var showPodcastLibrary = false
|
||||
@State private var readingProgress: [String: Double] = ReadingProgress.all()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||
onPodcast: {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
browsingBookmark = bookmark
|
||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
SkeletonRow(delay: Double(i) * 0.13)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.visible)
|
||||
}
|
||||
} else {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||
onOpen: { browsingBookmark = bookmark },
|
||||
onEdit: { editingBookmark = bookmark }
|
||||
)
|
||||
.onAppear { maybeLoadMore(bookmark) }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
Button {
|
||||
editingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Button {
|
||||
browsingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Open", systemImage: "globe")
|
||||
}
|
||||
Button {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
} label: {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
Button {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
} label: {
|
||||
Label("Convert to Podcast", systemImage: "headphones")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
@@ -102,6 +89,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
||||
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
@@ -118,6 +106,11 @@ struct BookmarksView: View {
|
||||
} label: {
|
||||
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
||||
}
|
||||
Button {
|
||||
showPodcastLibrary = true
|
||||
} label: {
|
||||
Label("Podcasts", systemImage: "headphones")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
@@ -133,6 +126,7 @@ struct BookmarksView: View {
|
||||
Image(systemName: viewModel.unreadFilter
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
Button { showSettings = true } label: {
|
||||
Image(systemName: "gearshape")
|
||||
@@ -141,16 +135,18 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ProgressView()
|
||||
}
|
||||
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
|
||||
ContentUnavailableView(
|
||||
"No Bookmarks",
|
||||
systemImage: "bookmark",
|
||||
description: Text("Bookmarks you save will appear here.")
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
if viewModel.podcastPlayer.player != nil {
|
||||
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
|
||||
MiniPlayerView(vm: viewModel.podcastPlayer) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
@@ -160,9 +156,10 @@ struct BookmarksView: View {
|
||||
enrichmentBanner
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil)
|
||||
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||
@@ -178,7 +175,9 @@ struct BookmarksView: View {
|
||||
}
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude)
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: browsingBookmark) { _, new in
|
||||
@@ -193,6 +192,9 @@ struct BookmarksView: View {
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showPodcastLibrary) {
|
||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ final class BookmarksViewModel {
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
saveToCache(bookmarks)
|
||||
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
|
||||
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
|
||||
})
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -25,6 +25,29 @@ enum ReadingProgress {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Indeterminate page load bar
|
||||
|
||||
private struct PageLoadBar: View {
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let barWidth = geo.size.width * 0.38
|
||||
Capsule()
|
||||
.fill(.blue.opacity(0.75))
|
||||
.frame(width: barWidth, height: 3)
|
||||
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
|
||||
}
|
||||
.frame(height: 3)
|
||||
.clipped()
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowserState
|
||||
|
||||
@Observable
|
||||
@@ -196,18 +219,25 @@ struct BrowserView: View {
|
||||
let initialURL: URL
|
||||
let initialTitle: String
|
||||
let claude: ClaudeService
|
||||
let podcastPlayer: PodcastPlayerViewModel
|
||||
var onArchive: (() async -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var state: BrowserState
|
||||
@State private var readerMode = false
|
||||
@State private var showPodcast = false
|
||||
@State private var podcastVM = PodcastPlayerViewModel()
|
||||
@State private var archiveTapCount = 0
|
||||
@State private var navBackCount = 0
|
||||
@State private var navForwardCount = 0
|
||||
@State private var toolbarHidden = false
|
||||
|
||||
init(url: URL, title: String, claude: ClaudeService) {
|
||||
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) {
|
||||
self.initialURL = url
|
||||
self.initialTitle = title
|
||||
self.claude = claude
|
||||
self.podcastPlayer = podcastPlayer
|
||||
self.onArchive = onArchive
|
||||
self._state = State(initialValue: BrowserState(url: url))
|
||||
}
|
||||
|
||||
@@ -221,16 +251,17 @@ struct BrowserView: View {
|
||||
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.7))
|
||||
.fill(Color.blue.opacity(0.55))
|
||||
.frame(width: geo.size.width * state.readingProgress, height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
.animation(.linear(duration: 0.1), value: state.readingProgress)
|
||||
}
|
||||
|
||||
// Page loading sweep bar (replaces center spinner)
|
||||
if state.isLoading {
|
||||
ProgressView()
|
||||
.padding(.top, 8)
|
||||
PageLoadBar()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
|
||||
@@ -247,44 +278,28 @@ struct BrowserView: View {
|
||||
Image(systemName: "safari")
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Button { state.webView.goBack() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
}
|
||||
.disabled(!state.canGoBack)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { state.webView.goForward() } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.disabled(!state.canGoForward)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if readerMode { state.exitReaderMode(); readerMode = false }
|
||||
else { state.enterReaderMode { readerMode = true } }
|
||||
} label: {
|
||||
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
|
||||
}
|
||||
.disabled(state.isLoading)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { showPodcast = true } label: {
|
||||
Image(systemName: "headphones")
|
||||
}
|
||||
.disabled(state.isLoading)
|
||||
|
||||
Spacer()
|
||||
|
||||
ShareLink(item: state.currentURL ?? initialURL) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomBar
|
||||
.offset(y: toolbarHidden ? 88 : 0)
|
||||
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
|
||||
}
|
||||
.onChange(of: state.readingProgress) { old, new in
|
||||
let delta = new - old
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0)) {
|
||||
if delta > 0.012 && new > 0.07 {
|
||||
toolbarHidden = true
|
||||
} else if delta < -0.005 || new < 0.05 {
|
||||
toolbarHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: state.isLoading) { _, loading in
|
||||
if loading {
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
let urlStr = (state.currentURL ?? initialURL).absoluteString
|
||||
let p = state.readingProgress
|
||||
@@ -292,12 +307,101 @@ struct BrowserView: View {
|
||||
}
|
||||
.sheet(isPresented: $showPodcast) {
|
||||
PodcastPlayerView(
|
||||
vm: podcastVM,
|
||||
vm: podcastPlayer,
|
||||
articleUrl: (state.currentURL ?? initialURL).absoluteString,
|
||||
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
|
||||
claude: claude,
|
||||
stopOnDismiss: true
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
navBackCount += 1
|
||||
state.webView.goBack()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(!state.canGoBack)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
navForwardCount += 1
|
||||
state.webView.goForward()
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(!state.canGoForward)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if readerMode { state.exitReaderMode(); readerMode = false }
|
||||
else { state.enterReaderMode { readerMode = true } }
|
||||
} label: {
|
||||
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(state.isLoading)
|
||||
|
||||
if onArchive != nil { Spacer() }
|
||||
|
||||
if let onArchive {
|
||||
Button {
|
||||
archiveTapCount += 1
|
||||
Task { await onArchive() }
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "archivebox")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
let currentUrl = (state.currentURL ?? initialURL).absoluteString
|
||||
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
|
||||
podcastPlayer.start(
|
||||
articleUrl: currentUrl,
|
||||
articleTitle: currentTitle,
|
||||
claude: claude,
|
||||
parentBookmarkUrl: initialURL.absoluteString
|
||||
)
|
||||
showPodcast = true
|
||||
} label: {
|
||||
Image(systemName: "headphones")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(state.isLoading)
|
||||
|
||||
Spacer()
|
||||
|
||||
ShareLink(item: state.currentURL ?? initialURL) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(.bar)
|
||||
.overlay(alignment: .top) { Divider() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,26 @@ final class PodcastPlayerViewModel {
|
||||
var duration: Double = 1
|
||||
var currentArticleUrl: String = ""
|
||||
var currentArticleTitle: String = ""
|
||||
var playbackSpeed: Float = 1.0
|
||||
|
||||
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
|
||||
|
||||
var isGenerating: Bool {
|
||||
switch phase {
|
||||
case .generating, .downloading: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: Any?
|
||||
private var interruptionObserver: Any?
|
||||
private var currentPodcastTitle: String = "Marks Podcast"
|
||||
|
||||
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
|
||||
// Idempotent — don't restart the same article if already playing
|
||||
if currentArticleUrl == articleUrl && player != nil { return }
|
||||
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
|
||||
// Idempotent — don't restart if already generating or playing this article
|
||||
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
||||
|
||||
// Teardown previous episode (saves its position)
|
||||
if player != nil { stop() }
|
||||
@@ -78,7 +90,7 @@ final class PodcastPlayerViewModel {
|
||||
)
|
||||
if job.isDone {
|
||||
phase = .downloading
|
||||
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title)
|
||||
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
|
||||
return
|
||||
}
|
||||
@@ -86,6 +98,7 @@ final class PodcastPlayerViewModel {
|
||||
let errMsg = job.error ?? "Generation failed"
|
||||
Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg])
|
||||
phase = .failed(errMsg)
|
||||
pollingTask = nil
|
||||
return
|
||||
}
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
@@ -94,6 +107,7 @@ final class PodcastPlayerViewModel {
|
||||
} catch {
|
||||
Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription])
|
||||
phase = .failed(error.localizedDescription)
|
||||
pollingTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +119,10 @@ final class PodcastPlayerViewModel {
|
||||
pollingTask?.cancel()
|
||||
player?.pause()
|
||||
if let obs = timeObserver { player?.removeTimeObserver(obs) }
|
||||
if let obs = endObserver { NotificationCenter.default.removeObserver(obs) }
|
||||
endObserver = nil
|
||||
if let obs = interruptionObserver { NotificationCenter.default.removeObserver(obs) }
|
||||
interruptionObserver = nil
|
||||
player = nil
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
@@ -118,12 +136,18 @@ final class PodcastPlayerViewModel {
|
||||
c.playCommand.removeTarget(nil)
|
||||
c.pauseCommand.removeTarget(nil)
|
||||
c.changePlaybackPositionCommand.removeTarget(nil)
|
||||
c.skipBackwardCommand.removeTarget(nil)
|
||||
c.skipForwardCommand.removeTarget(nil)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let player else { return }
|
||||
if isPlaying { player.pause() } else { player.play() }
|
||||
if isPlaying {
|
||||
player.pause()
|
||||
} else {
|
||||
player.rate = playbackSpeed
|
||||
}
|
||||
isPlaying.toggle()
|
||||
updateNowPlaying()
|
||||
}
|
||||
@@ -134,6 +158,20 @@ final class PodcastPlayerViewModel {
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func skipForward15() {
|
||||
seek(to: min(currentTime + 15, duration))
|
||||
}
|
||||
|
||||
func skipBackward15() {
|
||||
seek(to: max(currentTime - 15, 0))
|
||||
}
|
||||
|
||||
func setPlaybackSpeed(_ speed: Float) {
|
||||
playbackSpeed = speed
|
||||
if isPlaying { player?.rate = speed }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func setupPlayer(url: URL, title: String) {
|
||||
currentPodcastTitle = title
|
||||
|
||||
@@ -158,6 +196,40 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to start when playback finishes, like standard media players
|
||||
endObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.isPlaying = false
|
||||
self.currentTime = 0
|
||||
self.player?.seek(to: .zero)
|
||||
PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration)
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
|
||||
// Pause on phone calls / Siri; resume when interruption ends
|
||||
interruptionObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self,
|
||||
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
||||
if type == .began {
|
||||
self.player?.pause()
|
||||
self.isPlaying = false
|
||||
self.updateNowPlaying()
|
||||
} else if type == .ended {
|
||||
let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
self.player?.rate = self.playbackSpeed
|
||||
self.isPlaying = true
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
|
||||
guard let self else { return }
|
||||
@@ -173,7 +245,7 @@ final class PodcastPlayerViewModel {
|
||||
setupRemoteCommands()
|
||||
phase = .ready(title: title)
|
||||
updateNowPlaying()
|
||||
p.play()
|
||||
p.rate = playbackSpeed
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
@@ -194,18 +266,48 @@ final class PodcastPlayerViewModel {
|
||||
self?.currentTime = e.positionTime
|
||||
return .success
|
||||
}
|
||||
c.skipBackwardCommand.isEnabled = true
|
||||
c.skipBackwardCommand.preferredIntervals = [15]
|
||||
c.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
self?.skipBackward15(); return .success
|
||||
}
|
||||
c.skipForwardCommand.isEnabled = true
|
||||
c.skipForwardCommand.preferredIntervals = [15]
|
||||
c.skipForwardCommand.addTarget { [weak self] _ in
|
||||
self?.skipForward15(); return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
var info: [String: Any] = [:]
|
||||
info[MPMediaItemPropertyTitle] = currentPodcastTitle
|
||||
info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? Double(playbackSpeed) : 0.0
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
|
||||
if let artwork = Self.nowPlayingArtwork {
|
||||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
|
||||
private static let nowPlayingArtwork: MPMediaItemArtwork? = {
|
||||
let size = CGSize(width: 300, height: 300)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
let image = renderer.image { ctx in
|
||||
UIColor.systemBlue.withAlphaComponent(0.15).setFill()
|
||||
ctx.fill(CGRect(origin: .zero, size: size))
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 140, weight: .regular)
|
||||
if let symbol = UIImage(systemName: "waveform.circle.fill", withConfiguration: config) {
|
||||
let tinted = symbol.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
|
||||
let origin = CGPoint(x: (size.width - tinted.size.width) / 2,
|
||||
y: (size.height - tinted.size.height) / 2)
|
||||
tinted.draw(at: origin)
|
||||
}
|
||||
}
|
||||
return MPMediaItemArtwork(boundsSize: size) { _ in image }
|
||||
}()
|
||||
|
||||
private static func statusLabel(_ status: String) -> String {
|
||||
switch status {
|
||||
case "queued": return "Queued…"
|
||||
@@ -243,7 +345,7 @@ struct PodcastPlayerView: View {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
if vm.player != nil {
|
||||
if !vm.currentArticleUrl.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { vm.stop(); dismiss() } label: {
|
||||
Image(systemName: "stop.circle")
|
||||
@@ -325,10 +427,53 @@ struct PodcastPlayerView: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Button { vm.togglePlayPause() } label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(.primary)
|
||||
HStack(spacing: 36) {
|
||||
Button { vm.skipBackward15() } label: {
|
||||
Image(systemName: "gobackward.15")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Button { vm.togglePlayPause() } label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Button { vm.skipForward15() } label: {
|
||||
Image(systemName: "goforward.15")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
|
||||
Button {
|
||||
vm.setPlaybackSpeed(speed)
|
||||
} label: {
|
||||
let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×"
|
||||
if speed == vm.playbackSpeed {
|
||||
Label(label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(Color(.systemGray5))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
if let url = URL(string: articleUrl) {
|
||||
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
|
||||
Label("Share Episode", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,6 +489,18 @@ struct PodcastPlayerView: View {
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
|
||||
} label: {
|
||||
Label("Retry", systemImage: "arrow.clockwise")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.blue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +522,7 @@ struct MiniPlayerView: View {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying)
|
||||
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
|
||||
.frame(width: 22)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -380,15 +537,20 @@ struct MiniPlayerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
vm.togglePlayPause()
|
||||
} label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.primary)
|
||||
if vm.isGenerating {
|
||||
ProgressView()
|
||||
.frame(width: 36, height: 36)
|
||||
} else {
|
||||
Button {
|
||||
vm.togglePlayPause()
|
||||
} label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
vm.stop()
|
||||
@@ -435,3 +597,149 @@ struct MiniPlayerView: View {
|
||||
return mins > 0 ? "\(mins) min left" : "Almost done"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Podcast library
|
||||
|
||||
struct PodcastLibraryView: View {
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
|
||||
@State private var entries: [PodcastEntry] = []
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if entries.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Podcasts",
|
||||
systemImage: "headphones",
|
||||
description: Text("Podcasts you generate will appear here.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(entries) { entry in
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.lineLimit(2)
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
vm.start(articleUrl: entry.articleUrl,
|
||||
articleTitle: entry.title ?? "",
|
||||
claude: claude)
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
deleteEntry(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
indexSet.forEach { deleteEntry(entries[$0]) }
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Podcasts")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.all() }
|
||||
}
|
||||
|
||||
private func deleteEntry(_ entry: PodcastEntry) {
|
||||
PodcastIndex.remove(articleUrl: entry.articleUrl)
|
||||
entries.removeAll { $0.articleUrl == entry.articleUrl }
|
||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Episode picker (multiple episodes per bookmark)
|
||||
|
||||
struct EpisodePickerView: View {
|
||||
let bookmark: Bookmark
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
|
||||
@State private var entries: [PodcastEntry] = []
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(entries) { entry in
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.lineLimit(2)
|
||||
if entry.articleUrl != bookmark.url {
|
||||
Text(entry.articleUrl)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
vm.start(articleUrl: entry.articleUrl,
|
||||
articleTitle: entry.title ?? bookmark.displayTitle,
|
||||
claude: claude)
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) { deleteEntry(entry) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Episodes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
|
||||
}
|
||||
|
||||
private func deleteEntry(_ entry: PodcastEntry) {
|
||||
PodcastIndex.remove(articleUrl: entry.articleUrl)
|
||||
entries.removeAll { $0.articleUrl == entry.articleUrl }
|
||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,30 +2,22 @@ import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var useSemanticSearch = false
|
||||
@State private var semanticResults: [Bookmark]? = nil
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(results) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
onOpen: { browsingBookmark = bookmark }
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -49,6 +41,19 @@ struct SearchView: View {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
|
||||
.onChange(of: searchText) { _, _ in scheduleSearch() }
|
||||
|
||||
@@ -48,32 +48,17 @@ struct TagsView: View {
|
||||
struct TagBookmarksView: View {
|
||||
let tag: String
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(filteredBookmarks) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
onOpen: { browsingBookmark = bookmark }
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -84,6 +69,19 @@ struct TagBookmarksView: View {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "tag")
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredBookmarks: [Bookmark] {
|
||||
|
||||
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:
|
||||
Marks: all
|
||||
ShareExtension: all
|
||||
MarksWidget: all
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
@@ -32,12 +33,37 @@ schemes:
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
MarksWidget:
|
||||
build:
|
||||
targets:
|
||||
Marks: all
|
||||
MarksWidget: all
|
||||
run:
|
||||
config: Debug
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
ShareExtension:
|
||||
build:
|
||||
targets:
|
||||
Marks: all
|
||||
ShareExtension: all
|
||||
run:
|
||||
config: Debug
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
targets:
|
||||
Marks:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Marks
|
||||
- path: MarksWidget/WidgetDataStore.swift
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks
|
||||
@@ -46,6 +72,8 @@ targets:
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
dependencies:
|
||||
- target: ShareExtension
|
||||
- target: MarksWidget
|
||||
- sdk: WidgetKit.framework
|
||||
|
||||
ShareExtension:
|
||||
type: app-extension
|
||||
@@ -59,3 +87,18 @@ targets:
|
||||
CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements
|
||||
SKIP_INSTALL: YES
|
||||
APPLICATION_EXTENSION_API_ONLY: YES
|
||||
|
||||
MarksWidget:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: MarksWidget
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksWidget
|
||||
INFOPLIST_FILE: MarksWidget/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: MarksWidget/MarksWidget.entitlements
|
||||
SKIP_INSTALL: YES
|
||||
dependencies:
|
||||
- sdk: WidgetKit.framework
|
||||
- sdk: SwiftUI.framework
|
||||
|
||||
Reference in New Issue
Block a user