Compare commits
55 Commits
e9373ceac3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
715175786b | ||
|
|
58dd589d5a | ||
|
|
4461689251 | ||
|
|
dbddf126f9 | ||
|
|
8a04c18627 | ||
|
|
e28c4d0003 | ||
|
|
6a694fcbcf | ||
|
|
b5d734efe1 | ||
|
|
55ef010370 | ||
|
|
fa3932c597 | ||
|
|
a739e0de80 | ||
|
|
d680af0547 | ||
|
|
e4a079bf11 | ||
|
|
583138fbe2 | ||
|
|
864ca679ce | ||
|
|
48b6522cf5 | ||
| 4621d4f606 | |||
|
|
97f4ac5150 | ||
|
|
4d6960c508 | ||
|
|
1cfb729cae | ||
|
|
ae3b3f11bf | ||
|
|
92a44faac3 | ||
|
|
d8f0e5d845 | ||
|
|
bf2ff59ade | ||
|
|
0a10d297cd | ||
|
|
66ab375ee0 | ||
|
|
a5c95c2e5f | ||
|
|
7cfe3eeed5 | ||
| 3002c1f59d | |||
|
|
5d51ac6833 | ||
|
|
e21a26db18 | ||
| 2d4cafa16e | |||
|
|
9965b1d634 | ||
| a4ebe77973 | |||
|
|
c13259c2b5 | ||
|
|
f4b527e70b | ||
|
|
c74f36a94d | ||
|
|
01c63d69d0 | ||
|
|
e0ae9cb95f | ||
|
|
0f1a35ab84 | ||
|
|
d75fb870d7 | ||
|
|
61cd78e080 | ||
|
|
e37444c62e | ||
|
|
a452c0d4f2 | ||
|
|
416a6ed3f8 | ||
|
|
a39e0377b5 | ||
|
|
63b6027902 | ||
|
|
17d10ec4c1 | ||
|
|
39b9303918 | ||
|
|
976469f5fe | ||
|
|
a82ae267b6 | ||
|
|
395cca08dd | ||
|
|
e2fc87b6aa | ||
| 8a8f865702 | |||
| 144f24b7a0 |
3
ios/Platform/AGENTS.md
Normal file
3
ios/Platform/AGENTS.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.
|
||||||
@@ -14,6 +14,17 @@
|
|||||||
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
|
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
|
||||||
A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; };
|
A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; };
|
||||||
A10051 /* EditableDraftCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10051 /* EditableDraftCard.swift */; };
|
A10051 /* EditableDraftCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10051 /* EditableDraftCard.swift */; };
|
||||||
|
A10060 /* TripModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10060 /* TripModels.swift */; };
|
||||||
|
A10061 /* TripsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10061 /* TripsAPI.swift */; };
|
||||||
|
A10062 /* TripsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10062 /* TripsViewModel.swift */; };
|
||||||
|
A10063 /* TripsHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10063 /* TripsHomeView.swift */; };
|
||||||
|
A10064 /* UpcomingTripsPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10064 /* UpcomingTripsPageView.swift */; };
|
||||||
|
A10065 /* PastTripsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10065 /* PastTripsSection.swift */; };
|
||||||
|
A10066 /* TripCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10066 /* TripCard.swift */; };
|
||||||
|
A10067 /* TripImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10067 /* TripImageView.swift */; };
|
||||||
|
A10068 /* TripPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10068 /* TripPlaceholderView.swift */; };
|
||||||
|
A10069 /* TripDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10069 /* TripDetailView.swift */; };
|
||||||
|
A10070 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10070 /* CameraView.swift */; };
|
||||||
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
|
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
|
||||||
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
|
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
|
||||||
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; };
|
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; };
|
||||||
@@ -52,8 +63,36 @@
|
|||||||
A10047 /* FeedManagementSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10047 /* FeedManagementSheet.swift */; };
|
A10047 /* FeedManagementSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10047 /* FeedManagementSheet.swift */; };
|
||||||
A10048 /* ScrollViewDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10048 /* ScrollViewDriver.swift */; };
|
A10048 /* ScrollViewDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10048 /* ScrollViewDriver.swift */; };
|
||||||
F20549752F805F5800AE8DF5 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F20549742F805F5800AE8DF5 /* ConfettiSwiftUI */; };
|
F20549752F805F5800AE8DF5 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F20549742F805F5800AE8DF5 /* ConfettiSwiftUI */; };
|
||||||
|
W10001 /* PlatformWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = W10010 /* PlatformWidgetBundle.swift */; };
|
||||||
|
W10002 /* PlatformWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = W10011 /* PlatformWidget.swift */; };
|
||||||
|
W10003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = W10012 /* Assets.xcassets */; };
|
||||||
|
W10004 /* PlatformWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = W10020 /* PlatformWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
W10030 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = G10010 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = W10040;
|
||||||
|
remoteInfo = PlatformWidgetExtension;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
W10050 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
W10004 /* PlatformWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
B10001 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformApp.swift; sourceTree = "<group>"; };
|
B10001 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformApp.swift; sourceTree = "<group>"; };
|
||||||
B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
@@ -62,6 +101,17 @@
|
|||||||
B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||||
B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = "<group>"; };
|
B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = "<group>"; };
|
||||||
B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = "<group>"; };
|
B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = "<group>"; };
|
||||||
|
B10060 /* TripModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripModels.swift; sourceTree = "<group>"; };
|
||||||
|
B10061 /* TripsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsAPI.swift; sourceTree = "<group>"; };
|
||||||
|
B10062 /* TripsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B10063 /* TripsHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsHomeView.swift; sourceTree = "<group>"; };
|
||||||
|
B10064 /* UpcomingTripsPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpcomingTripsPageView.swift; sourceTree = "<group>"; };
|
||||||
|
B10065 /* PastTripsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PastTripsSection.swift; sourceTree = "<group>"; };
|
||||||
|
B10066 /* TripCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripCard.swift; sourceTree = "<group>"; };
|
||||||
|
B10067 /* TripImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripImageView.swift; sourceTree = "<group>"; };
|
||||||
|
B10068 /* TripPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripPlaceholderView.swift; sourceTree = "<group>"; };
|
||||||
|
B10069 /* TripDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
B10070 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = "<group>"; };
|
||||||
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||||
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -100,7 +150,14 @@
|
|||||||
B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = "<group>"; };
|
B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = "<group>"; };
|
||||||
B10048 /* ScrollViewDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDriver.swift; sourceTree = "<group>"; };
|
B10048 /* ScrollViewDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDriver.swift; sourceTree = "<group>"; };
|
||||||
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
B10090 /* Platform.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Platform.entitlements; sourceTree = "<group>"; };
|
||||||
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
W10013 /* PlatformWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PlatformWidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||||
|
W10014 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
W10010 /* PlatformWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformWidgetBundle.swift; sourceTree = "<group>"; };
|
||||||
|
W10011 /* PlatformWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformWidget.swift; sourceTree = "<group>"; };
|
||||||
|
W10012 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
W10020 /* PlatformWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PlatformWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -112,6 +169,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
W10060 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -119,6 +183,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
F10002 /* Platform */,
|
F10002 /* Platform */,
|
||||||
|
W10070 /* PlatformWidget */,
|
||||||
F10020 /* Products */,
|
F10020 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -126,6 +191,7 @@
|
|||||||
F10002 /* Platform */ = {
|
F10002 /* Platform */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B10090 /* Platform.entitlements */,
|
||||||
B10001 /* PlatformApp.swift */,
|
B10001 /* PlatformApp.swift */,
|
||||||
B10002 /* ContentView.swift */,
|
B10002 /* ContentView.swift */,
|
||||||
B10003 /* Config.swift */,
|
B10003 /* Config.swift */,
|
||||||
@@ -157,6 +223,7 @@
|
|||||||
F10014 /* Assistant */,
|
F10014 /* Assistant */,
|
||||||
F10021 /* Feedback */,
|
F10021 /* Feedback */,
|
||||||
F10030 /* Reader */,
|
F10030 /* Reader */,
|
||||||
|
F10050 /* Trips */,
|
||||||
);
|
);
|
||||||
path = Features;
|
path = Features;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -266,6 +333,7 @@
|
|||||||
B10027 /* MacroRing.swift */,
|
B10027 /* MacroRing.swift */,
|
||||||
B10028 /* MacroBar.swift */,
|
B10028 /* MacroBar.swift */,
|
||||||
B10029 /* LoadingView.swift */,
|
B10029 /* LoadingView.swift */,
|
||||||
|
B10070 /* CameraView.swift */,
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -283,10 +351,23 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D10001 /* Platform.app */,
|
D10001 /* Platform.app */,
|
||||||
|
W10020 /* PlatformWidgetExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
W10070 /* PlatformWidget */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
W10013 /* PlatformWidgetExtension.entitlements */,
|
||||||
|
W10014 /* Info.plist */,
|
||||||
|
W10010 /* PlatformWidgetBundle.swift */,
|
||||||
|
W10011 /* PlatformWidget.swift */,
|
||||||
|
W10012 /* Assets.xcassets */,
|
||||||
|
);
|
||||||
|
path = PlatformWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F10021 /* Feedback */ = {
|
F10021 /* Feedback */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -343,6 +424,55 @@
|
|||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F10050 /* Trips */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F10051 /* Models */,
|
||||||
|
F10052 /* API */,
|
||||||
|
F10053 /* ViewModels */,
|
||||||
|
F10054 /* Views */,
|
||||||
|
);
|
||||||
|
path = Trips;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10051 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10060 /* TripModels.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10052 /* API */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10061 /* TripsAPI.swift */,
|
||||||
|
);
|
||||||
|
path = API;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10053 /* ViewModels */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10062 /* TripsViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = ViewModels;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10054 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10063 /* TripsHomeView.swift */,
|
||||||
|
B10064 /* UpcomingTripsPageView.swift */,
|
||||||
|
B10065 /* PastTripsSection.swift */,
|
||||||
|
B10066 /* TripCard.swift */,
|
||||||
|
B10067 /* TripImageView.swift */,
|
||||||
|
B10068 /* TripPlaceholderView.swift */,
|
||||||
|
B10069 /* TripDetailView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -353,18 +483,45 @@
|
|||||||
G10002 /* Sources */,
|
G10002 /* Sources */,
|
||||||
E10001 /* Frameworks */,
|
E10001 /* Frameworks */,
|
||||||
G10003 /* Resources */,
|
G10003 /* Resources */,
|
||||||
|
W10050 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
W10031 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Platform;
|
name = Platform;
|
||||||
productName = Platform;
|
productName = Platform;
|
||||||
productReference = D10001 /* Platform.app */;
|
productReference = D10001 /* Platform.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
W10040 /* PlatformWidgetExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = W10080 /* Build configuration list for PBXNativeTarget "PlatformWidgetExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
W10041 /* Sources */,
|
||||||
|
W10060 /* Frameworks */,
|
||||||
|
W10042 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = PlatformWidgetExtension;
|
||||||
|
productName = PlatformWidgetExtension;
|
||||||
|
productReference = W10020 /* PlatformWidgetExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
W10031 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = W10040 /* PlatformWidgetExtension */;
|
||||||
|
targetProxy = W10030 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
G10010 /* Project object */ = {
|
G10010 /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
@@ -376,6 +533,9 @@
|
|||||||
G10001 = {
|
G10001 = {
|
||||||
CreatedOnToolsVersion = 15.4;
|
CreatedOnToolsVersion = 15.4;
|
||||||
};
|
};
|
||||||
|
W10040 = {
|
||||||
|
CreatedOnToolsVersion = 15.4;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = H10001 /* Build configuration list for PBXProject "Platform" */;
|
buildConfigurationList = H10001 /* Build configuration list for PBXProject "Platform" */;
|
||||||
@@ -395,6 +555,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
G10001 /* Platform */,
|
G10001 /* Platform */,
|
||||||
|
W10040 /* PlatformWidgetExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -408,6 +569,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
W10042 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
W10003 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -422,6 +591,17 @@
|
|||||||
A10005 /* AuthManager.swift in Sources */,
|
A10005 /* AuthManager.swift in Sources */,
|
||||||
A10050 /* AppearanceManager.swift in Sources */,
|
A10050 /* AppearanceManager.swift in Sources */,
|
||||||
A10051 /* EditableDraftCard.swift in Sources */,
|
A10051 /* EditableDraftCard.swift in Sources */,
|
||||||
|
A10060 /* TripModels.swift in Sources */,
|
||||||
|
A10061 /* TripsAPI.swift in Sources */,
|
||||||
|
A10062 /* TripsViewModel.swift in Sources */,
|
||||||
|
A10063 /* TripsHomeView.swift in Sources */,
|
||||||
|
A10064 /* UpcomingTripsPageView.swift in Sources */,
|
||||||
|
A10065 /* PastTripsSection.swift in Sources */,
|
||||||
|
A10066 /* TripCard.swift in Sources */,
|
||||||
|
A10067 /* TripImageView.swift in Sources */,
|
||||||
|
A10068 /* TripPlaceholderView.swift in Sources */,
|
||||||
|
A10069 /* TripDetailView.swift in Sources */,
|
||||||
|
A10070 /* CameraView.swift in Sources */,
|
||||||
A10006 /* LoginView.swift in Sources */,
|
A10006 /* LoginView.swift in Sources */,
|
||||||
A10007 /* HomeView.swift in Sources */,
|
A10007 /* HomeView.swift in Sources */,
|
||||||
A10008 /* HomeViewModel.swift in Sources */,
|
A10008 /* HomeViewModel.swift in Sources */,
|
||||||
@@ -461,6 +641,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
W10041 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
W10001 /* PlatformWidgetBundle.swift in Sources */,
|
||||||
|
W10002 /* PlatformWidget.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -588,6 +777,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Platform/Platform.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||||
@@ -617,6 +807,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Platform/Platform.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||||
@@ -641,6 +832,62 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
W10081 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = PlatformWidget/PlatformWidgetExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = PlatformWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = PlatformWidget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform.PlatformWidget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
W10082 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = PlatformWidget/PlatformWidgetExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = PlatformWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = PlatformWidget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform.PlatformWidget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -662,6 +909,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
W10080 /* Build configuration list for PBXNativeTarget "PlatformWidgetExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
W10081 /* Debug */,
|
||||||
|
W10082 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
7
ios/Platform/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Platform/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "0813d8074328ac339b3ad4d9e895dca3cbd4327633fce793259860c9993a0805",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "confettiswiftui",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/simibac/ConfettiSwiftUI",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9ae5bc2ce2149980884d4612321c3a266d6bf82c",
|
||||||
|
"version" : "3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -28,13 +28,43 @@ struct MainTabView: View {
|
|||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
@State private var confettiTrigger = 0
|
@State private var confettiTrigger = 0
|
||||||
@State private var readerVM = ReaderViewModel()
|
@State private var readerVM = ReaderViewModel()
|
||||||
|
@State private var tripsVM = TripsViewModel()
|
||||||
@State private var isAutoScrolling = false
|
@State private var isAutoScrolling = false
|
||||||
@State private var scrollSpeed: Double = 1.0
|
@State private var scrollSpeed: Double = 1.5
|
||||||
|
@State private var speedLevel = 0 // 0=slow, 1=med, 2=fast
|
||||||
|
@State private var previousTab = 0
|
||||||
|
|
||||||
private var showReader: Bool {
|
private var showReader: Bool {
|
||||||
auth.currentUser?.id != 4
|
auth.currentUser?.id != 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let speedLevels: [(String, String, Double)] = [
|
||||||
|
("Slow", "hare", 1.0),
|
||||||
|
("Med", "figure.walk", 2.0),
|
||||||
|
("Fast", "bolt.fill", 3.5),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dynamic icon + label for the search-role tab
|
||||||
|
private var actionIcon: String {
|
||||||
|
if selectedTab == 2 && isAutoScrolling {
|
||||||
|
return speedLevels[speedLevel].1
|
||||||
|
}
|
||||||
|
if selectedTab == 2 {
|
||||||
|
return "play.fill"
|
||||||
|
}
|
||||||
|
return "plus"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionLabel: String {
|
||||||
|
if selectedTab == 2 && isAutoScrolling {
|
||||||
|
return speedLevels[speedLevel].0
|
||||||
|
}
|
||||||
|
if selectedTab == 2 {
|
||||||
|
return "Play"
|
||||||
|
}
|
||||||
|
return "Add"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -51,89 +81,34 @@ struct MainTabView: View {
|
|||||||
ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed)
|
ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Tab("Trips", systemImage: "airplane", value: 4) {
|
||||||
|
TripsHomeView(vm: tripsVM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action button — separated circle on trailing side of tab bar
|
||||||
|
// Home/Fitness: quick add food (+)
|
||||||
|
// Reader: play/pause auto-scroll
|
||||||
|
Tab(value: 3, role: .search) {
|
||||||
|
Color.clear
|
||||||
|
} label: {
|
||||||
|
Label(actionLabel, systemImage: actionIcon)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tint(Color.accentWarm)
|
.tint(Color.accentWarm)
|
||||||
.tabBarMinimizeBehavior(.onScrollDown)
|
.tabBarMinimizeBehavior(.onScrollDown)
|
||||||
|
|
||||||
// Floating action button — context-dependent
|
// Feedback button (not on Reader)
|
||||||
|
if selectedTab != 2 {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack(alignment: .bottom) {
|
HStack {
|
||||||
if selectedTab != 2 {
|
|
||||||
FeedbackButton()
|
FeedbackButton()
|
||||||
.padding(.leading, 20)
|
.padding(.leading, 20)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if selectedTab == 2 && showReader {
|
|
||||||
// Reader: play/pause auto-scroll
|
|
||||||
Button {
|
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
|
||||||
isAutoScrolling.toggle()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: isAutoScrolling ? "pause.fill" : "play.fill")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
.background(isAutoScrolling ? Color.red.opacity(0.85) : Color.accentWarm)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 20)
|
|
||||||
} else {
|
|
||||||
// Home/Fitness: FAB (+) for food
|
|
||||||
Button { showAssistant = true } label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.title2.weight(.semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
.background(Color.accentWarm)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 20)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.bottom, 70)
|
.padding(.bottom, 70)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll speed controls (overlay when playing)
|
|
||||||
if isAutoScrolling && selectedTab == 2 {
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
scrollSpeed = max(0.25, scrollSpeed - 0.25)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "minus")
|
|
||||||
.font(.caption.weight(.bold))
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(String(format: "%.2fx", scrollSpeed))
|
|
||||||
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
scrollSpeed = min(3.0, scrollSpeed + 0.25)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.caption.weight(.bold))
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
|
||||||
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
|
|
||||||
.padding(.bottom, 140)
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
.animation(.spring(duration: 0.3), value: isAutoScrolling)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confettiCannon(
|
.confettiCannon(
|
||||||
@@ -150,17 +125,58 @@ struct MainTabView: View {
|
|||||||
.sheet(isPresented: $showAssistant) {
|
.sheet(isPresented: $showAssistant) {
|
||||||
AssistantSheetView(onFoodAdded: foodAdded)
|
AssistantSheetView(onFoodAdded: foodAdded)
|
||||||
}
|
}
|
||||||
|
.onOpenURL { url in
|
||||||
|
guard url.scheme == "platform" else { return }
|
||||||
|
switch url.host {
|
||||||
|
case "fitness":
|
||||||
|
selectedTab = 1
|
||||||
|
case "add-food":
|
||||||
|
selectedTab = 1
|
||||||
|
showAssistant = true
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
|
// Pre-load trips for all users
|
||||||
|
await tripsVM.loadTrips()
|
||||||
|
|
||||||
|
// Reader only for non-Madiha
|
||||||
guard showReader else { return }
|
guard showReader else { return }
|
||||||
let renderer = ArticleRenderer.shared
|
let renderer = ArticleRenderer.shared
|
||||||
renderer.attachToWindow()
|
renderer.attachToWindow()
|
||||||
await readerVM.loadInitial()
|
await readerVM.loadInitial()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedTab) { _, newTab in
|
.onChange(of: selectedTab) { oldTab, newTab in
|
||||||
// Stop auto-scroll when leaving Reader
|
if newTab == 3 {
|
||||||
|
// Action tab tapped — handle based on previous tab
|
||||||
|
handleActionTap(from: oldTab)
|
||||||
|
} else {
|
||||||
|
previousTab = newTab
|
||||||
if newTab != 2 { isAutoScrolling = false }
|
if newTab != 2 { isAutoScrolling = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleActionTap(from sourceTab: Int) {
|
||||||
|
if sourceTab == 2 {
|
||||||
|
if !isAutoScrolling {
|
||||||
|
// First tap: start at Slow
|
||||||
|
speedLevel = 0
|
||||||
|
scrollSpeed = speedLevels[0].2
|
||||||
|
isAutoScrolling = true
|
||||||
|
} else {
|
||||||
|
// Cycle: Slow → Med → Fast → Slow
|
||||||
|
speedLevel = (speedLevel + 1) % speedLevels.count
|
||||||
|
scrollSpeed = speedLevels[speedLevel].2
|
||||||
|
}
|
||||||
|
selectedTab = 2
|
||||||
|
} else {
|
||||||
|
// Home/Fitness: open food assistant, return to previous tab
|
||||||
|
showAssistant = true
|
||||||
|
selectedTab = sourceTab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func foodAdded() {
|
private func foodAdded() {
|
||||||
showAssistant = false
|
showAssistant = false
|
||||||
@@ -188,9 +204,10 @@ struct AssistantSheetView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
|
||||||
|
// Pill selector — tappable, syncs with swipe
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
tabButton("AI Chat", icon: "sparkles", index: 0)
|
tabPill("Quick Add", icon: "magnifyingglass", index: 0)
|
||||||
tabButton("Quick Add", icon: "magnifyingglass", index: 1)
|
tabPill("AI Chat", icon: "sparkles", index: 1)
|
||||||
}
|
}
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.background(Color.textTertiary.opacity(0.08))
|
.background(Color.textTertiary.opacity(0.08))
|
||||||
@@ -200,17 +217,22 @@ struct AssistantSheetView: View {
|
|||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
|
|
||||||
if selectedMode == 0 {
|
// Swipeable pages — Quick Add first
|
||||||
AssistantChatView(onFoodAdded: onFoodAdded)
|
TabView(selection: $selectedMode) {
|
||||||
} else {
|
|
||||||
FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded)
|
FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded)
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
AssistantChatView(onFoodAdded: onFoodAdded)
|
||||||
|
.tag(1)
|
||||||
}
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: selectedMode)
|
||||||
}
|
}
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tabButton(_ title: String, icon: String, index: Int) -> some View {
|
private func tabPill(_ title: String, icon: String, index: Int) -> some View {
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index }
|
withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class AuthManager {
|
final class AuthManager {
|
||||||
@@ -10,6 +11,12 @@ final class AuthManager {
|
|||||||
private let api = APIClient.shared
|
private let api = APIClient.shared
|
||||||
private let loggedInKey = "isLoggedIn"
|
private let loggedInKey = "isLoggedIn"
|
||||||
|
|
||||||
|
// App Group for sharing auth with widget
|
||||||
|
private static let appGroup = "group.com.quadjourney.platform"
|
||||||
|
static var sharedDefaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: appGroup) ?? .standard
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
|
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
|
||||||
}
|
}
|
||||||
@@ -46,9 +53,11 @@ final class AuthManager {
|
|||||||
if response.authenticated, let user = response.user {
|
if response.authenticated, let user = response.user {
|
||||||
currentUser = user
|
currentUser = user
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
|
syncCookieToWidget()
|
||||||
} else {
|
} else {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||||
|
clearWidgetAuth()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
@@ -68,6 +77,9 @@ final class AuthManager {
|
|||||||
currentUser = response.user
|
currentUser = response.user
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
UserDefaults.standard.set(true, forKey: loggedInKey)
|
UserDefaults.standard.set(true, forKey: loggedInKey)
|
||||||
|
clearWidgetAuth() // Clear previous user's cached data
|
||||||
|
syncCookieToWidget()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
} catch let apiError as APIError {
|
} catch let apiError as APIError {
|
||||||
error = apiError.localizedDescription
|
error = apiError.localizedDescription
|
||||||
@@ -83,5 +95,26 @@ final class AuthManager {
|
|||||||
currentUser = nil
|
currentUser = nil
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||||
|
clearWidgetAuth()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Auth Sync
|
||||||
|
|
||||||
|
/// Copy the session cookie to App Group UserDefaults so the widget can authenticate.
|
||||||
|
private func syncCookieToWidget() {
|
||||||
|
guard let url = URL(string: "https://dash.quadjourney.com"),
|
||||||
|
let cookies = HTTPCookieStorage.shared.cookies(for: url) else { return }
|
||||||
|
|
||||||
|
for cookie in cookies where cookie.name == "session" {
|
||||||
|
Self.sharedDefaults.set(cookie.value, forKey: "widget_sessionCookie")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearWidgetAuth() {
|
||||||
|
Self.sharedDefaults.removeObject(forKey: "widget_sessionCookie")
|
||||||
|
Self.sharedDefaults.removeObject(forKey: "widget_totalCalories")
|
||||||
|
Self.sharedDefaults.removeObject(forKey: "widget_calorieGoal")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import PhotosUI
|
|||||||
|
|
||||||
struct AssistantChatView: View {
|
struct AssistantChatView: View {
|
||||||
@State private var vm = AssistantViewModel()
|
@State private var vm = AssistantViewModel()
|
||||||
|
@State private var showCamera = false
|
||||||
|
@State private var showPhotoPicker = false
|
||||||
var onFoodAdded: (() -> Void)?
|
var onFoodAdded: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -83,19 +85,32 @@ struct AssistantChatView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Input bar
|
// Input bar
|
||||||
HStack(spacing: 10) {
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
PhotosPicker(selection: $vm.selectedPhoto, matching: .images) {
|
Menu {
|
||||||
|
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||||
|
Button {
|
||||||
|
showCamera = true
|
||||||
|
} label: {
|
||||||
|
Label("Take Photo", systemImage: "camera")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showPhotoPicker = true
|
||||||
|
} label: {
|
||||||
|
Label("Photo Library", systemImage: "photo.on.rectangle")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
Image(systemName: "camera.fill")
|
Image(systemName: "camera.fill")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
TextField("Describe your food...", text: $vm.inputText)
|
// Multiline text input — Return = new line, send button submits
|
||||||
|
TextField("Describe your food...", text: $vm.inputText, axis: .vertical)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.onSubmit {
|
.lineLimit(1...5)
|
||||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
||||||
Task { await vm.send() }
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
@@ -110,6 +125,7 @@ struct AssistantChatView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.disabled(vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty || vm.isLoading)
|
.disabled(vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty || vm.isLoading)
|
||||||
|
.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -126,6 +142,13 @@ struct AssistantChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.photosPicker(isPresented: $showPhotoPicker, selection: $vm.selectedPhoto, matching: .images)
|
||||||
|
.fullScreenCover(isPresented: $showCamera) {
|
||||||
|
CameraView { image in
|
||||||
|
showCamera = false
|
||||||
|
Task { await vm.handleCameraImage(image) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chat Bubble
|
// MARK: - Chat Bubble
|
||||||
|
|||||||
@@ -173,6 +173,23 @@ final class AssistantViewModel {
|
|||||||
selectedPhoto = nil
|
selectedPhoto = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleCameraImage(_ image: UIImage) async {
|
||||||
|
let maxDim: CGFloat = 800
|
||||||
|
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
|
||||||
|
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||||
|
let resized = renderer.image { _ in
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let jpegData = resized.jpegData(compressionQuality: 0.7) {
|
||||||
|
let base64 = jpegData.base64EncodedString()
|
||||||
|
imageDataUrl = "data:image/jpeg;base64,\(base64)"
|
||||||
|
messages.append(ChatMessage(role: "user", content: "[Photo attached]"))
|
||||||
|
await doSend(action: "chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func draftToDict(_ draft: FitnessDraft) -> [String: Any] {
|
private func draftToDict(_ draft: FitnessDraft) -> [String: Any] {
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import SwiftUI
|
|||||||
struct EditableDraftCard: View {
|
struct EditableDraftCard: View {
|
||||||
@Bindable var vm: AssistantViewModel
|
@Bindable var vm: AssistantViewModel
|
||||||
@State private var isEditing = false
|
@State private var isEditing = false
|
||||||
|
// Base values per 1.0 quantity — set once when draft appears
|
||||||
|
@State private var baseCalories: Double?
|
||||||
|
@State private var baseProtein: Double?
|
||||||
|
@State private var baseCarbs: Double?
|
||||||
|
@State private var baseFat: Double?
|
||||||
|
@State private var baseSugar: Double?
|
||||||
|
@State private var baseFiber: Double?
|
||||||
|
|
||||||
private var draft: FitnessDraft? { vm.currentDraft }
|
private var draft: FitnessDraft? { vm.currentDraft }
|
||||||
|
|
||||||
@@ -135,8 +142,34 @@ struct EditableDraftCard: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
|
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
.onAppear {
|
||||||
|
// Capture base per-unit values on first appearance
|
||||||
|
if baseCalories == nil, let d = vm.currentDraft {
|
||||||
|
let q = max(d.quantity, 0.01)
|
||||||
|
baseCalories = d.calories / q
|
||||||
|
baseProtein = d.protein / q
|
||||||
|
baseCarbs = d.carbs / q
|
||||||
|
baseFat = d.fat / q
|
||||||
|
baseSugar = d.sugar / q
|
||||||
|
baseFiber = d.fiber / q
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recalculate macros from base values when quantity changes
|
||||||
|
private func recalculateMacros() {
|
||||||
|
guard let bc = baseCalories, let bp = baseProtein,
|
||||||
|
let bca = baseCarbs, let bf = baseFat,
|
||||||
|
let bs = baseSugar, let bfi = baseFiber else { return }
|
||||||
|
let q = vm.currentDraft?.quantity ?? 1.0
|
||||||
|
vm.currentDraft?.calories = (bc * q).rounded()
|
||||||
|
vm.currentDraft?.protein = (bp * q * 10).rounded() / 10
|
||||||
|
vm.currentDraft?.carbs = (bca * q * 10).rounded() / 10
|
||||||
|
vm.currentDraft?.fat = (bf * q * 10).rounded() / 10
|
||||||
|
vm.currentDraft?.sugar = (bs * q * 10).rounded() / 10
|
||||||
|
vm.currentDraft?.fiber = (bfi * q * 10).rounded() / 10
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Editable macro cell
|
// MARK: - Editable macro cell
|
||||||
|
|
||||||
@@ -196,7 +229,10 @@ struct EditableDraftCard: View {
|
|||||||
private var quantityBinding: Binding<String> {
|
private var quantityBinding: Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { String(format: "%.1f", vm.currentDraft?.quantity ?? 1) },
|
get: { String(format: "%.1f", vm.currentDraft?.quantity ?? 1) },
|
||||||
set: { vm.currentDraft?.quantity = Double($0) ?? 1 }
|
set: {
|
||||||
|
vm.currentDraft?.quantity = Double($0) ?? 1
|
||||||
|
recalculateMacros()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ struct FitnessAPI {
|
|||||||
try await api.get("\(basePath)/foods/\(id)")
|
try await api.get("\(basePath)/foods/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteFood(id: String) async throws -> SuccessResponse {
|
||||||
|
try await api.delete("\(basePath)/foods/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFood(id: String, body: UpdateFoodRequest) async throws -> Food {
|
||||||
|
try await api.patch("\(basePath)/foods/\(id)", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Templates
|
// MARK: - Templates
|
||||||
|
|
||||||
func getTemplates() async throws -> [MealTemplate] {
|
func getTemplates() async throws -> [MealTemplate] {
|
||||||
|
|||||||
@@ -235,6 +235,20 @@ struct UpdateEntryRequest: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Update Food Request
|
||||||
|
|
||||||
|
struct UpdateFoodRequest: Encodable {
|
||||||
|
var name: String?
|
||||||
|
var brand: String?
|
||||||
|
var caloriesPerBase: Double?
|
||||||
|
var proteinPerBase: Double?
|
||||||
|
var carbsPerBase: Double?
|
||||||
|
var fatPerBase: Double?
|
||||||
|
var sugarPerBase: Double?
|
||||||
|
var fiberPerBase: Double?
|
||||||
|
var baseUnit: String?
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Delete Response
|
// MARK: - Delete Response
|
||||||
|
|
||||||
struct SuccessResponse: Decodable {
|
struct SuccessResponse: Decodable {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct FoodLibraryView: View {
|
struct FoodLibraryView: View {
|
||||||
@State private var vm = FoodSearchViewModel()
|
@State private var vm = FoodSearchViewModel()
|
||||||
@State private var selectedFood: Food?
|
@State private var selectedFood: Food?
|
||||||
|
@State private var editingFood: Food?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -76,6 +77,21 @@ struct FoodLibraryView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
editingFood = food
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
_ = try? await FitnessAPI().deleteFood(id: food.id)
|
||||||
|
await vm.loadInitial()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
Divider().padding(.leading, 20)
|
Divider().padding(.leading, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,5 +108,133 @@ struct FoodLibraryView: View {
|
|||||||
.sheet(item: $selectedFood) { food in
|
.sheet(item: $selectedFood) { food in
|
||||||
AddFoodSheet(food: food)
|
AddFoodSheet(food: food)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $editingFood) { food in
|
||||||
|
EditFoodSheet(food: food) {
|
||||||
|
Task { await vm.loadInitial() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edit Food Sheet
|
||||||
|
|
||||||
|
struct EditFoodSheet: View {
|
||||||
|
let food: Food
|
||||||
|
var onSave: () -> Void = {}
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var brand: String
|
||||||
|
@State private var calories: String
|
||||||
|
@State private var protein: String
|
||||||
|
@State private var carbs: String
|
||||||
|
@State private var fat: String
|
||||||
|
@State private var sugar: String
|
||||||
|
@State private var fiber: String
|
||||||
|
@State private var baseUnit: String
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
init(food: Food, onSave: @escaping () -> Void = {}) {
|
||||||
|
self.food = food
|
||||||
|
self.onSave = onSave
|
||||||
|
_name = State(initialValue: food.name)
|
||||||
|
_brand = State(initialValue: food.brand ?? "")
|
||||||
|
_calories = State(initialValue: String(Int(food.caloriesPerBase)))
|
||||||
|
_protein = State(initialValue: String(Int(food.proteinPerBase)))
|
||||||
|
_carbs = State(initialValue: String(Int(food.carbsPerBase)))
|
||||||
|
_fat = State(initialValue: String(Int(food.fatPerBase)))
|
||||||
|
_sugar = State(initialValue: String(Int(food.sugarPerBase)))
|
||||||
|
_fiber = State(initialValue: String(Int(food.fiberPerBase)))
|
||||||
|
_baseUnit = State(initialValue: food.baseUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Food name", text: $name)
|
||||||
|
TextField("Brand (optional)", text: $brand)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Nutrition per \(baseUnit)") {
|
||||||
|
macroField("Calories", text: $calories, color: .emerald)
|
||||||
|
macroField("Protein (g)", text: $protein, color: .macroProtein)
|
||||||
|
macroField("Carbs (g)", text: $carbs, color: .macroCarbs)
|
||||||
|
macroField("Fat (g)", text: $fat, color: .macroFat)
|
||||||
|
macroField("Sugar (g)", text: $sugar, color: .orange)
|
||||||
|
macroField("Fiber (g)", text: $fiber, color: .green)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Unit") {
|
||||||
|
TextField("Base unit", text: $baseUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Food")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button {
|
||||||
|
save()
|
||||||
|
} label: {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty || isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroField(_ label: String, text: Binding<String>, color: Color) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Spacer()
|
||||||
|
TextField("0", text: text)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
isSaving = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let request = UpdateFoodRequest(
|
||||||
|
name: name,
|
||||||
|
brand: brand.isEmpty ? nil : brand,
|
||||||
|
caloriesPerBase: Double(calories),
|
||||||
|
proteinPerBase: Double(protein),
|
||||||
|
carbsPerBase: Double(carbs),
|
||||||
|
fatPerBase: Double(fat),
|
||||||
|
sugarPerBase: Double(sugar),
|
||||||
|
fiberPerBase: Double(fiber),
|
||||||
|
baseUnit: baseUnit
|
||||||
|
)
|
||||||
|
_ = try await FitnessAPI().updateFood(id: food.id, body: request)
|
||||||
|
onSave()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct FoodSearchView: View {
|
|||||||
var onFoodAdded: (() -> Void)?
|
var onFoodAdded: (() -> Void)?
|
||||||
@State private var vm = FoodSearchViewModel()
|
@State private var vm = FoodSearchViewModel()
|
||||||
@State private var selectedFood: Food?
|
@State private var selectedFood: Food?
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -15,6 +16,7 @@ struct FoodSearchView: View {
|
|||||||
TextField("Search foods...", text: $vm.searchText)
|
TextField("Search foods...", text: $vm.searchText)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.focused($searchFocused)
|
||||||
if !vm.searchText.isEmpty {
|
if !vm.searchText.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
vm.searchText = ""
|
vm.searchText = ""
|
||||||
@@ -44,6 +46,11 @@ struct FoodSearchView: View {
|
|||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.task {
|
.task {
|
||||||
await vm.loadInitial()
|
await vm.loadInitial()
|
||||||
|
if isSheet {
|
||||||
|
// Small delay so sheet animation completes first
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
searchFocused = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: vm.searchText) {
|
.onChange(of: vm.searchText) {
|
||||||
vm.search()
|
vm.search()
|
||||||
|
|||||||
@@ -2,17 +2,14 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TodayView: View {
|
struct TodayView: View {
|
||||||
@State private var vm = TodayViewModel()
|
@State private var vm = TodayViewModel()
|
||||||
|
@State private var animated = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Date selector
|
|
||||||
dateSelector
|
dateSelector
|
||||||
|
|
||||||
// Macro summary
|
|
||||||
macroSummary
|
macroSummary
|
||||||
|
|
||||||
// Meal sections
|
|
||||||
if vm.entries.isEmpty && !vm.isLoading {
|
if vm.entries.isEmpty && !vm.isLoading {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "fork.knife",
|
icon: "fork.knife",
|
||||||
@@ -20,7 +17,8 @@ struct TodayView: View {
|
|||||||
subtitle: "Tap + to log your first meal"
|
subtitle: "Tap + to log your first meal"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ForEach(vm.mealGroups, id: \.0) { mealType, entries in
|
ForEach(Array(vm.mealGroups.enumerated()), id: \.1.0) { index, group in
|
||||||
|
let (mealType, entries) = group
|
||||||
MealSectionView(
|
MealSectionView(
|
||||||
mealType: mealType,
|
mealType: mealType,
|
||||||
entries: entries,
|
entries: entries,
|
||||||
@@ -28,6 +26,9 @@ struct TodayView: View {
|
|||||||
Task { await vm.deleteEntry(entry) }
|
Task { await vm.deleteEntry(entry) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.opacity(animated ? 1 : 0)
|
||||||
|
.offset(y: animated ? 0 : 20)
|
||||||
|
.animation(.spring(response: 0.5, dampingFraction: 0.8).delay(Double(index) * 0.08), value: animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +42,27 @@ struct TodayView: View {
|
|||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.task {
|
.task {
|
||||||
await vm.load()
|
await vm.load()
|
||||||
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
|
||||||
|
animated = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: vm.selectedDate) {
|
.onChange(of: vm.selectedDate) {
|
||||||
Task { await vm.load() }
|
animated = false
|
||||||
|
Task {
|
||||||
|
await vm.load()
|
||||||
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
|
||||||
|
animated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Re-animate when switching back to this tab
|
||||||
|
animated = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
|
||||||
|
animated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class HomeViewModel {
|
final class HomeViewModel {
|
||||||
@@ -32,6 +33,13 @@ final class HomeViewModel {
|
|||||||
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
|
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
|
||||||
calorieGoal = repo.goal?.calories ?? 2000
|
calorieGoal = repo.goal?.calories ?? 2000
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
|
// Write to App Group UserDefaults for widget (fallback cache)
|
||||||
|
let shared = AuthManager.sharedDefaults
|
||||||
|
shared.set(totalCalories, forKey: "widget_totalCalories")
|
||||||
|
shared.set(calorieGoal, forKey: "widget_calorieGoal")
|
||||||
|
shared.set(Date(), forKey: "widget_lastUpdate")
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background Image
|
// MARK: - Background Image
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ final class ReaderViewModel {
|
|||||||
if reset {
|
if reset {
|
||||||
offset = 0
|
offset = 0
|
||||||
hasMore = true
|
hasMore = true
|
||||||
// DO NOT set entries = [] — causes full list teardown + empty flash
|
|
||||||
}
|
}
|
||||||
guard !isLoading else { return }
|
guard !isLoading else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -80,7 +79,7 @@ final class ReaderViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let list = try await fetchEntries(offset: 0)
|
let list = try await fetchEntries(offset: 0)
|
||||||
// Atomic swap — SwiftUI diffs by Identifiable.id
|
// Atomic swap — fresh list replaces old (no duplicates possible)
|
||||||
entries = list.entries
|
entries = list.entries
|
||||||
total = list.total
|
total = list.total
|
||||||
offset = list.entries.count
|
offset = list.entries.count
|
||||||
@@ -96,11 +95,21 @@ final class ReaderViewModel {
|
|||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let list = try await fetchEntries(offset: offset)
|
// Use entries.count as offset — accounts for deduplication and
|
||||||
entries.append(contentsOf: list.entries)
|
// avoids offset drift when entries change status during scroll
|
||||||
|
let list = try await fetchEntries(offset: entries.count)
|
||||||
|
|
||||||
|
if list.entries.isEmpty {
|
||||||
|
hasMore = false
|
||||||
|
} else {
|
||||||
|
// Deduplicate — only append entries with IDs not already in the array
|
||||||
|
let existingIDs = Set(entries.map(\.id))
|
||||||
|
let newEntries = list.entries.filter { !existingIDs.contains($0.id) }
|
||||||
|
entries.append(contentsOf: newEntries)
|
||||||
total = list.total
|
total = list.total
|
||||||
offset += list.entries.count
|
offset = entries.count
|
||||||
hasMore = offset < list.total
|
hasMore = newEntries.count > 0
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ struct ArticleWebView: UIViewRepresentable {
|
|||||||
|
|
||||||
// Only reload if content meaningfully changed
|
// Only reload if content meaningfully changed
|
||||||
guard context.coordinator.lastHTML != newHTML else { return }
|
guard context.coordinator.lastHTML != newHTML else { return }
|
||||||
|
|
||||||
let isUpgrade = context.coordinator.lastHTML != nil
|
let isUpgrade = context.coordinator.lastHTML != nil
|
||||||
context.coordinator.lastHTML = newHTML
|
context.coordinator.lastHTML = newHTML
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct EntryListView: View {
|
|||||||
@State private var cumulativeDown: CGFloat = 0
|
@State private var cumulativeDown: CGFloat = 0
|
||||||
@State private var cumulativeUp: CGFloat = 0
|
@State private var cumulativeUp: CGFloat = 0
|
||||||
@State private var markedByScroll: Set<Int> = []
|
@State private var markedByScroll: Set<Int> = []
|
||||||
|
@State private var deferredReadIDs: Set<Int> = [] // IDs to mark read when auto-scroll stops
|
||||||
|
|
||||||
// Viewport height — use the first connected scene's screen.
|
// Viewport height — use the first connected scene's screen.
|
||||||
private var viewportHeight: CGFloat {
|
private var viewportHeight: CGFloat {
|
||||||
@@ -39,7 +40,9 @@ struct EntryListView: View {
|
|||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
// Auto-scroll engine — zero-size, drives parent UIScrollView
|
// Auto-scroll engine — zero-size, drives parent UIScrollView
|
||||||
ScrollViewDriver(isScrolling: $isAutoScrolling, speed: scrollSpeed)
|
ScrollViewDriver(isScrolling: $isAutoScrolling, speed: scrollSpeed) {
|
||||||
|
Task { await vm.loadMore() }
|
||||||
|
}
|
||||||
.frame(width: 0, height: 0)
|
.frame(width: 0, height: 0)
|
||||||
|
|
||||||
if isCardView {
|
if isCardView {
|
||||||
@@ -59,7 +62,15 @@ struct EntryListView: View {
|
|||||||
// Stop auto-scroll on navigation return
|
// Stop auto-scroll on navigation return
|
||||||
isAutoScrolling = false
|
isAutoScrolling = false
|
||||||
}
|
}
|
||||||
|
.onChange(of: isAutoScrolling) { _, scrolling in
|
||||||
|
if !scrolling {
|
||||||
|
flushDeferredReads()
|
||||||
|
}
|
||||||
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
|
// Flush any deferred read marks before refreshing
|
||||||
|
flushDeferredReads()
|
||||||
|
markedByScroll.removeAll()
|
||||||
await vm.refresh()
|
await vm.refresh()
|
||||||
}
|
}
|
||||||
.navigationDestination(for: ReaderEntry.self) { entry in
|
.navigationDestination(for: ReaderEntry.self) { entry in
|
||||||
@@ -113,10 +124,15 @@ struct EntryListView: View {
|
|||||||
|
|
||||||
markedByScroll.insert(entryId)
|
markedByScroll.insert(entryId)
|
||||||
|
|
||||||
|
if isAutoScrolling {
|
||||||
|
// Defer visual update — contentSize changes cause jitter
|
||||||
|
// during auto-scroll. Collect IDs, apply when scroll stops.
|
||||||
|
deferredReadIDs.insert(entryId)
|
||||||
|
} else {
|
||||||
|
// Manual scroll — apply immediately
|
||||||
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) {
|
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) {
|
||||||
vm.entries[idx].status = "read"
|
vm.entries[idx].status = "read"
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
let api = ReaderAPI()
|
let api = ReaderAPI()
|
||||||
try? await api.markEntries(ids: [entryId], status: "read")
|
try? await api.markEntries(ids: [entryId], status: "read")
|
||||||
@@ -124,13 +140,36 @@ struct EntryListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flush Deferred Reads
|
||||||
|
|
||||||
|
private func flushDeferredReads() {
|
||||||
|
guard !deferredReadIDs.isEmpty else { return }
|
||||||
|
let ids = Array(deferredReadIDs)
|
||||||
|
deferredReadIDs.removeAll()
|
||||||
|
|
||||||
|
// Apply all visual updates at once
|
||||||
|
for id in ids {
|
||||||
|
if let idx = vm.entries.firstIndex(where: { $0.id == id }) {
|
||||||
|
vm.entries[idx].status = "read"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single batched API call
|
||||||
|
Task {
|
||||||
|
let api = ReaderAPI()
|
||||||
|
try? await api.markEntries(ids: ids, status: "read")
|
||||||
|
vm.counters = try? await api.getCounters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Card Layout
|
// MARK: - Card Layout
|
||||||
|
|
||||||
private var cardLayout: some View {
|
private var cardLayout: some View {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 14) {
|
||||||
ForEach(vm.entries) { entry in
|
ForEach(vm.entries) { entry in
|
||||||
scrollTracked(entry,
|
scrollTracked(entry,
|
||||||
content: NavigationLink(value: entry) {
|
content: NavigationLink(value: entry) {
|
||||||
@@ -142,6 +181,7 @@ struct EntryListView: View {
|
|||||||
entryContextMenu(entry: entry, vm: vm)
|
entryContextMenu(entry: entry, vm: vm)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.onAppear { loadMoreIfNeeded(for: entry) }
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMoreTrigger
|
loadMoreTrigger
|
||||||
@@ -149,7 +189,7 @@ struct EntryListView: View {
|
|||||||
Spacer(minLength: 80)
|
Spacer(minLength: 80)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 4)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - List Layout
|
// MARK: - List Layout
|
||||||
@@ -167,6 +207,7 @@ struct EntryListView: View {
|
|||||||
entryContextMenu(entry: entry, vm: vm)
|
entryContextMenu(entry: entry, vm: vm)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.onAppear { loadMoreIfNeeded(for: entry) }
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, 36)
|
.padding(.leading, 36)
|
||||||
@@ -178,14 +219,25 @@ struct EntryListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger loadMore when an entry near the bottom appears
|
||||||
|
private func loadMoreIfNeeded(for entry: ReaderEntry) {
|
||||||
|
let entries = vm.entries
|
||||||
|
guard entries.count >= 5 else { return }
|
||||||
|
let threshold = entries[entries.count - 5].id
|
||||||
|
if entry.id == threshold {
|
||||||
|
Task { await vm.loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var loadMoreTrigger: some View {
|
private var loadMoreTrigger: some View {
|
||||||
Group {
|
Group {
|
||||||
if vm.isLoadingMore {
|
if vm.isLoadingMore {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else if vm.entries.count > 0 {
|
||||||
|
// Fallback trigger at the very bottom
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(height: 1)
|
.frame(height: 40)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await vm.loadMore() }
|
Task { await vm.loadMore() }
|
||||||
}
|
}
|
||||||
@@ -205,9 +257,12 @@ struct EntryCardView: View {
|
|||||||
AsyncImage(url: thumbURL) { phase in
|
AsyncImage(url: thumbURL) { phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .success(let image):
|
case .success(let image):
|
||||||
|
GeometryReader { geo in
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: geo.size.width, height: 180)
|
||||||
|
}
|
||||||
.frame(height: 180)
|
.frame(height: 180)
|
||||||
.clipped()
|
.clipped()
|
||||||
default:
|
default:
|
||||||
@@ -270,7 +325,7 @@ struct EntryCardView: View {
|
|||||||
}
|
}
|
||||||
.background(Color.surfaceCard)
|
.background(Color.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
|
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,67 +4,33 @@ struct ReaderTabView: View {
|
|||||||
@Bindable var vm: ReaderViewModel
|
@Bindable var vm: ReaderViewModel
|
||||||
@Binding var isAutoScrolling: Bool
|
@Binding var isAutoScrolling: Bool
|
||||||
@Binding var scrollSpeed: Double
|
@Binding var scrollSpeed: Double
|
||||||
@State private var selectedSubTab = 0
|
|
||||||
@State private var showFeedSheet = false
|
@State private var showFeedSheet = false
|
||||||
@State private var showFeedManagement = false
|
@State private var showFeedManagement = false
|
||||||
@State private var isCardView = true
|
@State private var isCardView = true
|
||||||
|
|
||||||
|
private var subtitleText: String {
|
||||||
|
if let counters = vm.counters, counters.totalUnread > 0 {
|
||||||
|
return "\(counters.totalUnread) unread"
|
||||||
|
}
|
||||||
|
return "All caught up"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
// Entry list as the main content — scrolls under the glass nav bar
|
||||||
// Sub-tab selector
|
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
|
||||||
HStack(spacing: 0) {
|
.background(Color.canvas)
|
||||||
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
|
.navigationTitle("Reader")
|
||||||
Button {
|
.navigationSubtitle(subtitleText)
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
selectedSubTab = index
|
.toolbar {
|
||||||
switch index {
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
case 0: vm.applyFilter(.unread)
|
|
||||||
case 1: vm.applyFilter(.starred)
|
|
||||||
case 2: vm.applyFilter(.all)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(tab)
|
|
||||||
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
|
|
||||||
|
|
||||||
if index == 0, let counters = vm.counters, counters.totalUnread > 0 {
|
|
||||||
Text("\(counters.totalUnread)")
|
|
||||||
.font(.caption2.weight(.bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color.accentWarm)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.background {
|
|
||||||
if selectedSubTab == index {
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.accentWarm.opacity(0.12))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Inline controls: grid/list + more menu
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
isCardView.toggle()
|
isCardView.toggle()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
|
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
@@ -95,51 +61,9 @@ struct ReaderTabView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis")
|
Image(systemName: "ellipsis")
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
}
|
|
||||||
.padding(.leading)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
// Feed filter bar
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
feedFilterChip("All", isSelected: isAllSelected) {
|
|
||||||
let tab = selectedSubTab
|
|
||||||
switch tab {
|
|
||||||
case 0: vm.applyFilter(.unread)
|
|
||||||
case 1: vm.applyFilter(.starred)
|
|
||||||
case 2: vm.applyFilter(.all)
|
|
||||||
default: vm.applyFilter(.unread)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(vm.feeds) { feed in
|
|
||||||
let count = vm.counters?.count(forFeed: feed.id) ?? 0
|
|
||||||
feedFilterChip(
|
|
||||||
feed.title,
|
|
||||||
count: selectedSubTab == 0 ? count : nil,
|
|
||||||
isSelected: vm.currentFilter == .feed(feed.id)
|
|
||||||
) {
|
|
||||||
vm.applyFilter(.feed(feed.id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
.frame(height: 44)
|
|
||||||
|
|
||||||
// Entry list
|
|
||||||
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color.canvas)
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
.sheet(isPresented: $showFeedSheet) {
|
.sheet(isPresented: $showFeedSheet) {
|
||||||
AddFeedSheet(vm: vm)
|
AddFeedSheet(vm: vm)
|
||||||
}
|
}
|
||||||
@@ -150,46 +74,8 @@ struct ReaderTabView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
ArticleRenderer.shared.reWarmIfNeeded()
|
ArticleRenderer.shared.reWarmIfNeeded()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedSubTab) { _, _ in
|
|
||||||
isAutoScrolling = false
|
|
||||||
}
|
|
||||||
.onChange(of: vm.currentFilter) { _, _ in
|
.onChange(of: vm.currentFilter) { _, _ in
|
||||||
isAutoScrolling = false
|
isAutoScrolling = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private var subTabs: [String] { ["Unread", "Starred", "All"] }
|
|
||||||
|
|
||||||
private var isAllSelected: Bool {
|
|
||||||
switch vm.currentFilter {
|
|
||||||
case .unread, .starred, .all: return true
|
|
||||||
default: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func feedFilterChip(
|
|
||||||
_ title: String,
|
|
||||||
count: Int? = nil,
|
|
||||||
isSelected: Bool,
|
|
||||||
action: @escaping () -> Void
|
|
||||||
) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(title)
|
|
||||||
.font(.caption.weight(isSelected ? .semibold : .regular))
|
|
||||||
.lineLimit(1)
|
|
||||||
if let count, count > 0 {
|
|
||||||
Text("\(count)")
|
|
||||||
.font(.system(size: 10).weight(.bold))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(isSelected ? Color.accentWarm : Color.textSecondary)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(isSelected ? Color.accentWarm.opacity(0.12) : Color.surfaceCard)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import UIKit
|
|||||||
struct ScrollViewDriver: UIViewRepresentable {
|
struct ScrollViewDriver: UIViewRepresentable {
|
||||||
@Binding var isScrolling: Bool
|
@Binding var isScrolling: Bool
|
||||||
let speed: Double // 1.0 = 60pt/sec
|
let speed: Double // 1.0 = 60pt/sec
|
||||||
|
var onNearBottom: (() -> Void)? = nil
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
let view = DriverView()
|
let view = DriverView()
|
||||||
@@ -21,6 +22,7 @@ struct ScrollViewDriver: UIViewRepresentable {
|
|||||||
let coordinator = context.coordinator
|
let coordinator = context.coordinator
|
||||||
coordinator.speed = speed
|
coordinator.speed = speed
|
||||||
coordinator.isScrollingBinding = $isScrolling
|
coordinator.isScrollingBinding = $isScrolling
|
||||||
|
coordinator.onNearBottom = onNearBottom
|
||||||
|
|
||||||
if isScrolling && coordinator.displayLink == nil {
|
if isScrolling && coordinator.displayLink == nil {
|
||||||
coordinator.startScrolling(in: driver)
|
coordinator.startScrolling(in: driver)
|
||||||
@@ -54,8 +56,10 @@ struct ScrollViewDriver: UIViewRepresentable {
|
|||||||
var displayLink: CADisplayLink?
|
var displayLink: CADisplayLink?
|
||||||
var speed: Double = 1.0
|
var speed: Double = 1.0
|
||||||
var isScrollingBinding: Binding<Bool>?
|
var isScrollingBinding: Binding<Bool>?
|
||||||
|
var onNearBottom: (() -> Void)?
|
||||||
private var originalDelegate: UIScrollViewDelegate?
|
private var originalDelegate: UIScrollViewDelegate?
|
||||||
private var delegateInstalled = false
|
private var delegateInstalled = false
|
||||||
|
private var loadMoreTriggered = false
|
||||||
|
|
||||||
func findScrollView(from view: UIView) {
|
func findScrollView(from view: UIView) {
|
||||||
var current: UIView? = view.superview
|
var current: UIView? = view.superview
|
||||||
@@ -78,7 +82,6 @@ struct ScrollViewDriver: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startScrolling(in view: UIView) {
|
func startScrolling(in view: UIView) {
|
||||||
// Re-find scroll view if needed
|
|
||||||
if scrollView == nil {
|
if scrollView == nil {
|
||||||
findScrollView(from: view)
|
findScrollView(from: view)
|
||||||
}
|
}
|
||||||
@@ -104,20 +107,29 @@ struct ScrollViewDriver: UIViewRepresentable {
|
|||||||
let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom
|
let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom
|
||||||
guard maxOffset > 0 else { return }
|
guard maxOffset > 0 else { return }
|
||||||
|
|
||||||
// 60pt/sec at 1.0x speed, scaled by actual frame duration
|
|
||||||
let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp)
|
let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp)
|
||||||
let newY = min(sv.contentOffset.y + delta, maxOffset)
|
let newY = min(sv.contentOffset.y + delta, maxOffset)
|
||||||
|
|
||||||
sv.contentOffset.y = newY
|
sv.contentOffset.y = newY
|
||||||
|
|
||||||
// Notify delegate so tab bar minimize behavior triggers
|
|
||||||
originalDelegate?.scrollViewDidScroll?(sv)
|
originalDelegate?.scrollViewDidScroll?(sv)
|
||||||
|
|
||||||
// Stop at bottom
|
// Trigger load more when within 500pt of bottom
|
||||||
if newY >= maxOffset - 1 {
|
let distanceToBottom = maxOffset - newY
|
||||||
stopAndNotify()
|
if distanceToBottom < 500 && !loadMoreTriggered {
|
||||||
|
loadMoreTriggered = true
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onNearBottom?()
|
||||||
|
// Reset after a delay so it can trigger again for the next page
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||||
|
self?.loadMoreTriggered = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't stop at bottom — contentSize may grow after loadMore.
|
||||||
|
// The tick keeps running; if no more content, it just idles at maxOffset.
|
||||||
|
}
|
||||||
|
|
||||||
private func stopAndNotify() {
|
private func stopAndNotify() {
|
||||||
stopScrolling()
|
stopScrolling()
|
||||||
|
|||||||
19
ios/Platform/Platform/Features/Trips/API/TripsAPI.swift
Normal file
19
ios/Platform/Platform/Features/Trips/API/TripsAPI.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TripsAPI {
|
||||||
|
private let api = APIClient.shared
|
||||||
|
private let basePath = "/api/trips"
|
||||||
|
|
||||||
|
func getTrips() async throws -> [Trip] {
|
||||||
|
let response: TripsResponse = try await api.get("\(basePath)/trips")
|
||||||
|
return response.trips
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTrip(id: String) async throws -> Trip {
|
||||||
|
try await api.get("\(basePath)/trip/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTripDetail(id: String) async throws -> TripDetail {
|
||||||
|
try await api.get("\(basePath)/trip/\(id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
130
ios/Platform/Platform/Features/Trips/Models/TripModels.swift
Normal file
130
ios/Platform/Platform/Features/Trips/Models/TripModels.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Trip: Codable, Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let startDate: String
|
||||||
|
let endDate: String
|
||||||
|
let coverImage: String?
|
||||||
|
let createdAt: String?
|
||||||
|
|
||||||
|
var imageURL: URL? {
|
||||||
|
guard let cover = coverImage, !cover.isEmpty else { return nil }
|
||||||
|
return URL(string: "\(Config.gatewayURL)/api/trips\(cover)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateRange: String {
|
||||||
|
let start = Self.formatDisplay(startDate)
|
||||||
|
let end = Self.formatDisplay(endDate)
|
||||||
|
return "\(start) – \(end)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var tripLength: String {
|
||||||
|
guard let s = Self.parseDate(startDate),
|
||||||
|
let e = Self.parseDate(endDate) else { return "" }
|
||||||
|
let days = Calendar.current.dateComponents([.day], from: s, to: e).day ?? 0
|
||||||
|
return days == 1 ? "1 day" : "\(days) days"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isUpcoming: Bool {
|
||||||
|
guard let start = Self.parseDate(startDate) else { return false }
|
||||||
|
return start > Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isActive: Bool {
|
||||||
|
guard let start = Self.parseDate(startDate),
|
||||||
|
let end = Self.parseDate(endDate) else { return false }
|
||||||
|
let now = Date()
|
||||||
|
return start <= now && now <= end
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPast: Bool {
|
||||||
|
guard let end = Self.parseDate(endDate) else { return false }
|
||||||
|
return end < Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
var year: String {
|
||||||
|
String(startDate.prefix(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date helpers
|
||||||
|
|
||||||
|
private static let apiFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let displayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "MMM d"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func parseDate(_ str: String) -> Date? {
|
||||||
|
apiFormatter.date(from: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatDisplay(_ str: String) -> String {
|
||||||
|
guard let date = parseDate(str) else { return str }
|
||||||
|
return displayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TripsResponse: Codable {
|
||||||
|
let trips: [Trip]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trip Detail (full response from /api/trip/{id})
|
||||||
|
|
||||||
|
struct TripDetail: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let startDate: String
|
||||||
|
let endDate: String
|
||||||
|
let transportations: [TripTransportation]
|
||||||
|
let lodging: [TripLodging]
|
||||||
|
let locations: [TripLocation]
|
||||||
|
let notes: [TripNote]
|
||||||
|
let aiSuggestions: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TripTransportation: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let type: String?
|
||||||
|
let flightNumber: String?
|
||||||
|
let fromLocation: String?
|
||||||
|
let toLocation: String?
|
||||||
|
let date: String?
|
||||||
|
let endDate: String?
|
||||||
|
let startTime: String?
|
||||||
|
let endTime: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TripLodging: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let location: String?
|
||||||
|
let checkIn: String?
|
||||||
|
let checkOut: String?
|
||||||
|
let reservationNumber: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TripLocation: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let category: String?
|
||||||
|
let visitDate: String?
|
||||||
|
let address: String?
|
||||||
|
let latitude: Double?
|
||||||
|
let longitude: Double?
|
||||||
|
let startTime: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TripNote: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String?
|
||||||
|
let content: String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class TripsViewModel {
|
||||||
|
var trips: [Trip] = []
|
||||||
|
var isLoading = true
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
private let api = TripsAPI()
|
||||||
|
private var hasLoaded = false
|
||||||
|
|
||||||
|
var upcomingTrips: [Trip] {
|
||||||
|
trips.filter { $0.isUpcoming || $0.isActive }
|
||||||
|
.sorted { $0.startDate < $1.startDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
var pastTrips: [Trip] {
|
||||||
|
trips.filter { $0.isPast }
|
||||||
|
.sorted { $0.startDate > $1.startDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTrips() async {
|
||||||
|
guard !hasLoaded else { return }
|
||||||
|
hasLoaded = true
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
trips = try await api.getTrips()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
trips = try await api.getTrips()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Displays past trips in a horizontal scroll with compact cards.
|
||||||
|
/// Adapted from Apple's Wishlist TripCollectionView.
|
||||||
|
struct PastTripsSection: View {
|
||||||
|
let trips: [Trip]
|
||||||
|
var namespace: Namespace.ID
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !trips.isEmpty {
|
||||||
|
Section {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(trips) { trip in
|
||||||
|
NavigationLink {
|
||||||
|
TripDetailView(trip: trip)
|
||||||
|
.navigationTransition(
|
||||||
|
.zoom(sourceID: trip.id, in: namespace))
|
||||||
|
} label: {
|
||||||
|
TripCard(trip: trip, size: .compact)
|
||||||
|
.matchedTransitionSource(id: trip.id, in: namespace)
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollTargetLayout()
|
||||||
|
}
|
||||||
|
.scrollTargetBehavior(.viewAligned)
|
||||||
|
.scrollClipDisabled()
|
||||||
|
.scrollIndicators(.hidden, axes: .horizontal)
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
} header: {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Past Trips")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
|
||||||
|
Text("Your travel memories")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
ios/Platform/Platform/Features/Trips/Views/TripCard.swift
Normal file
64
ios/Platform/Platform/Features/Trips/Views/TripCard.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A card view displaying a trip's photo and metadata.
|
||||||
|
/// Adapted from Apple's Wishlist TripCard — supports compact and expanded sizes.
|
||||||
|
struct TripCard: View {
|
||||||
|
let trip: Trip
|
||||||
|
let size: Size
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: size.width, height: size.height)
|
||||||
|
.clipShape(.rect(cornerRadius: 16))
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
if !trip.tripLength.isEmpty {
|
||||||
|
Text(trip.tripLength.uppercased())
|
||||||
|
.font(.footnote)
|
||||||
|
.fontWidth(.condensed)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(4)
|
||||||
|
.background(.regularMaterial, in: .rect(cornerRadius: 8))
|
||||||
|
.padding([.leading, .bottom], 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(trip.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
|
||||||
|
Text(trip.dateRange)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.frame(width: size.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card sizes
|
||||||
|
|
||||||
|
extension TripCard {
|
||||||
|
enum Size {
|
||||||
|
case compact
|
||||||
|
case expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TripCard.Size {
|
||||||
|
var width: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .compact: 180
|
||||||
|
case .expanded: 325
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var height: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .compact: 200
|
||||||
|
case .expanded: 260
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
521
ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift
Normal file
521
ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Polished Trip Detail with continuous timeline.
|
||||||
|
/// Hero image → stats overlay → day-by-day timeline with type-specific cards.
|
||||||
|
struct TripDetailView: View {
|
||||||
|
let trip: Trip
|
||||||
|
@State private var detail: TripDetail?
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var appeared = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Hero
|
||||||
|
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
||||||
|
.scaledToFill()
|
||||||
|
.containerRelativeFrame(.horizontal)
|
||||||
|
.frame(height: 510)
|
||||||
|
.clipped()
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
tripStatsOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 60)
|
||||||
|
} else if let detail {
|
||||||
|
let timeline = buildTimeline(detail)
|
||||||
|
if timeline.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
timelineView(timeline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentMargins(.bottom, 50, for: .scrollContent)
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
.background(Color.canvas)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .largeTitle) {
|
||||||
|
HStack {
|
||||||
|
Text(trip.name)
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
.fixedSize()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.toolbarTitleDisplayMode(.inline)
|
||||||
|
.navigationTitle(trip.name)
|
||||||
|
.toolbarRole(.editor)
|
||||||
|
.task {
|
||||||
|
do { detail = try await TripsAPI().getTripDetail(id: trip.id) } catch {}
|
||||||
|
isLoading = false
|
||||||
|
withAnimation(.easeOut(duration: 0.6).delay(0.1)) { appeared = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline Builder
|
||||||
|
|
||||||
|
private func buildTimeline(_ detail: TripDetail) -> [TimelineDay] {
|
||||||
|
var items: [TimelineItem] = []
|
||||||
|
let tripStart = Trip.parseDate(trip.startDate)
|
||||||
|
|
||||||
|
for t in detail.transportations {
|
||||||
|
let date = t.date ?? ""
|
||||||
|
let time = t.startTime ?? ""
|
||||||
|
items.append(TimelineItem(
|
||||||
|
date: date, time: time, type: .transport,
|
||||||
|
title: t.name,
|
||||||
|
subtitle: [t.fromLocation, t.toLocation].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " → "),
|
||||||
|
detail1: t.flightNumber,
|
||||||
|
detail2: t.type,
|
||||||
|
iconName: transportIcon(t.type),
|
||||||
|
iconColor: .blue
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
for l in detail.lodging {
|
||||||
|
let date = String((l.checkIn ?? "").prefix(10))
|
||||||
|
let time = l.checkIn?.contains("T") == true ? String(l.checkIn!.suffix(from: l.checkIn!.index(l.checkIn!.startIndex, offsetBy: 11))) : ""
|
||||||
|
let nights: String? = {
|
||||||
|
guard let ci = l.checkIn, let co = l.checkOut,
|
||||||
|
let d1 = Trip.parseDate(String(ci.prefix(10))),
|
||||||
|
let d2 = Trip.parseDate(String(co.prefix(10))) else { return nil }
|
||||||
|
let n = Calendar.current.dateComponents([.day], from: d1, to: d2).day ?? 0
|
||||||
|
return n > 0 ? "\(n) night\(n == 1 ? "" : "s")" : nil
|
||||||
|
}()
|
||||||
|
items.append(TimelineItem(
|
||||||
|
date: date, time: time, type: .lodging,
|
||||||
|
title: l.name,
|
||||||
|
subtitle: l.location ?? "",
|
||||||
|
detail1: nights,
|
||||||
|
detail2: l.checkOut != nil ? "Check-out: \(Trip.formatDisplay(String(l.checkOut!.prefix(10))))" : nil,
|
||||||
|
iconName: "bed.double.fill",
|
||||||
|
iconColor: Color.accentWarm
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
for loc in detail.locations {
|
||||||
|
let raw = loc.visitDate ?? ""
|
||||||
|
let date = String(raw.prefix(10))
|
||||||
|
let time: String = raw.contains("T") ? String(raw.suffix(from: raw.index(raw.startIndex, offsetBy: 11))) : (loc.startTime ?? "")
|
||||||
|
items.append(TimelineItem(
|
||||||
|
date: date, time: time, type: .place,
|
||||||
|
title: loc.name,
|
||||||
|
subtitle: loc.address ?? "",
|
||||||
|
detail1: loc.category,
|
||||||
|
detail2: nil,
|
||||||
|
iconName: "mappin.and.ellipse",
|
||||||
|
iconColor: .red
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort { a, b in
|
||||||
|
if a.date == b.date { return a.time < b.time }
|
||||||
|
return a.date < b.date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
var days: [TimelineDay] = []
|
||||||
|
var currentDate = ""
|
||||||
|
var currentItems: [TimelineItem] = []
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let d = item.date.isEmpty ? "No date" : item.date
|
||||||
|
if d != currentDate {
|
||||||
|
if !currentItems.isEmpty {
|
||||||
|
let dayNum = dayNumber(currentDate, from: tripStart)
|
||||||
|
days.append(TimelineDay(date: currentDate, dayNumber: dayNum, items: currentItems))
|
||||||
|
}
|
||||||
|
currentDate = d
|
||||||
|
currentItems = [item]
|
||||||
|
} else {
|
||||||
|
currentItems.append(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !currentItems.isEmpty {
|
||||||
|
let dayNum = dayNumber(currentDate, from: tripStart)
|
||||||
|
days.append(TimelineDay(date: currentDate, dayNumber: dayNum, items: currentItems))
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline View
|
||||||
|
|
||||||
|
private func timelineView(_ days: [TimelineDay]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(Array(days.enumerated()), id: \.0) { dayIdx, day in
|
||||||
|
|
||||||
|
// Day header
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Day number badge on the timeline
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentWarm)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
Text(day.dayNumber != nil ? "\(day.dayNumber!)" : "•")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
if let num = day.dayNumber {
|
||||||
|
Text("Day \(num)")
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
Text(formatDayHeader(day.date))
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, dayIdx == 0 ? 20 : 28)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.opacity(appeared ? 1 : 0)
|
||||||
|
.offset(y: appeared ? 0 : 10)
|
||||||
|
.animation(.easeOut(duration: 0.4).delay(Double(dayIdx) * 0.05), value: appeared)
|
||||||
|
|
||||||
|
// Items
|
||||||
|
ForEach(Array(day.items.enumerated()), id: \.0) { idx, item in
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
// Timeline rail
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Line above icon
|
||||||
|
if dayIdx > 0 || idx > 0 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.textTertiary.opacity(0.2))
|
||||||
|
.frame(width: 2, height: 12)
|
||||||
|
} else {
|
||||||
|
Spacer().frame(height: 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
Circle()
|
||||||
|
.fill(item.iconColor.opacity(0.15))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: item.iconName)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(item.iconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line below icon
|
||||||
|
if idx < day.items.count - 1 || dayIdx < days.count - 1 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.textTertiary.opacity(0.2))
|
||||||
|
.frame(width: 2)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 40)
|
||||||
|
|
||||||
|
// Card
|
||||||
|
cardView(item)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.opacity(appeared ? 1 : 0)
|
||||||
|
.offset(y: appeared ? 0 : 15)
|
||||||
|
.animation(.easeOut(duration: 0.4).delay(Double(dayIdx) * 0.05 + Double(idx) * 0.03), value: appeared)
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Type-Specific Cards
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardView(_ item: TimelineItem) -> some View {
|
||||||
|
switch item.type {
|
||||||
|
case .transport:
|
||||||
|
transportCard(item)
|
||||||
|
case .lodging:
|
||||||
|
lodgingCard(item)
|
||||||
|
case .place:
|
||||||
|
placeCard(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transportCard(_ item: TimelineItem) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if !item.time.isEmpty {
|
||||||
|
Text(formatTime(item.time))
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: item.iconName)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.blue.opacity(0.6))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !item.subtitle.isEmpty {
|
||||||
|
// Route bar
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
let parts = item.subtitle.components(separatedBy: " → ")
|
||||||
|
if parts.count == 2 {
|
||||||
|
Text(parts[0])
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(parts[1])
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
} else {
|
||||||
|
Text(item.subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let flight = item.detail1, !flight.isEmpty {
|
||||||
|
Text(flight)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.blue.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lodgingCard(_ item: TimelineItem) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if !item.time.isEmpty {
|
||||||
|
Text(formatTime(item.time))
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let nights = item.detail1, !nights.isEmpty {
|
||||||
|
Text(nights)
|
||||||
|
.font(.caption2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.accentWarm.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !item.subtitle.isEmpty {
|
||||||
|
Label(item.subtitle, systemImage: "location")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let checkout = item.detail2, !checkout.isEmpty {
|
||||||
|
Text(checkout)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.accentWarm.opacity(0.04))
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func placeCard(_ item: TimelineItem) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
if !item.time.isEmpty {
|
||||||
|
Text(formatTime(item.time))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let category = item.detail1, !category.isEmpty {
|
||||||
|
Text(category.uppercased())
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.fontWidth(.condensed)
|
||||||
|
.foregroundStyle(Color.red.opacity(0.7))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.red.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats Overlay
|
||||||
|
|
||||||
|
private var tripStatsOverlay: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(trip.dateRange)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
.fontWeight(.regular)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if !trip.tripLength.isEmpty {
|
||||||
|
Label(trip.tripLength, systemImage: "calendar")
|
||||||
|
.font(.footnote)
|
||||||
|
.fontWidth(.condensed)
|
||||||
|
}
|
||||||
|
if let d = detail {
|
||||||
|
if !d.lodging.isEmpty {
|
||||||
|
Label("\(d.lodging.count) stays", systemImage: "bed.double")
|
||||||
|
.font(.footnote)
|
||||||
|
.fontWidth(.condensed)
|
||||||
|
}
|
||||||
|
if !d.transportations.isEmpty {
|
||||||
|
Label("\(d.transportations.count) flights", systemImage: "airplane")
|
||||||
|
.font(.footnote)
|
||||||
|
.fontWidth(.condensed)
|
||||||
|
}
|
||||||
|
if !d.locations.isEmpty {
|
||||||
|
Label("\(d.locations.count) places", systemImage: "mappin")
|
||||||
|
.font(.footnote)
|
||||||
|
.fontWidth(.condensed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background {
|
||||||
|
GradientOverlay(style: .ultraThinMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "map.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(Color.accentWarm.opacity(0.3))
|
||||||
|
Text("No details yet")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
Text("Add lodging, flights, and places on the web")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatDayHeader(_ date: String) -> String {
|
||||||
|
guard let d = Trip.parseDate(date) else { return date }
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "EEEE, MMM d"
|
||||||
|
return f.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ time: String) -> String {
|
||||||
|
let clean = time.prefix(5)
|
||||||
|
guard clean.count == 5, let hour = Int(clean.prefix(2)), let min = Int(clean.suffix(2)) else {
|
||||||
|
return String(time.prefix(5))
|
||||||
|
}
|
||||||
|
let ampm = hour >= 12 ? "PM" : "AM"
|
||||||
|
let h = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour)
|
||||||
|
return String(format: "%d:%02d %@", h, min, ampm)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayNumber(_ dateStr: String, from start: Date?) -> Int? {
|
||||||
|
guard let start, let date = Trip.parseDate(dateStr) else { return nil }
|
||||||
|
let days = Calendar.current.dateComponents([.day], from: start, to: date).day ?? 0
|
||||||
|
return days + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transportIcon(_ type: String?) -> String {
|
||||||
|
switch type {
|
||||||
|
case "plane": return "airplane"
|
||||||
|
case "train": return "tram.fill"
|
||||||
|
case "car", "rental": return "car.fill"
|
||||||
|
case "bus": return "bus.fill"
|
||||||
|
case "ferry": return "ferry.fill"
|
||||||
|
default: return "arrow.triangle.swap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Models
|
||||||
|
|
||||||
|
private struct TimelineDay {
|
||||||
|
let date: String
|
||||||
|
let dayNumber: Int?
|
||||||
|
let items: [TimelineItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TimelineItem {
|
||||||
|
let date: String
|
||||||
|
let time: String
|
||||||
|
let type: ItemType
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let detail1: String?
|
||||||
|
let detail2: String?
|
||||||
|
let iconName: String
|
||||||
|
let iconColor: Color
|
||||||
|
|
||||||
|
enum ItemType {
|
||||||
|
case transport, lodging, place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gradient Overlay
|
||||||
|
|
||||||
|
struct GradientOverlay<Style: ShapeStyle>: View {
|
||||||
|
let style: Style
|
||||||
|
var body: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(style)
|
||||||
|
.mask {
|
||||||
|
MeshGradient(width: 2, height: 2, points: [
|
||||||
|
SIMD2<Float>(0.0, 0.0), SIMD2<Float>(1.0, 0.0),
|
||||||
|
SIMD2<Float>(0.0, 1.0), SIMD2<Float>(1.0, 1.0)
|
||||||
|
], colors: [
|
||||||
|
.clear, .clear,
|
||||||
|
.black, .clear
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Displays a trip image from a URL, with gradient placeholder for missing images.
|
||||||
|
/// Adapted from Apple's Wishlist TripImageView pattern.
|
||||||
|
struct TripImageView: View {
|
||||||
|
let url: URL?
|
||||||
|
var fallbackName: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let url {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
if let image = phase.image {
|
||||||
|
// Rectangle overlay pattern from Wishlist — constrains AsyncImage properly
|
||||||
|
Rectangle()
|
||||||
|
.fill(.background)
|
||||||
|
.overlay {
|
||||||
|
image.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
} else if phase.error != nil {
|
||||||
|
placeholderGradient
|
||||||
|
} else {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.surfaceCard)
|
||||||
|
.overlay {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholderGradient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderGradient: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.accentWarm.opacity(0.6),
|
||||||
|
Color.accentWarm.opacity(0.3),
|
||||||
|
Color.emerald.opacity(0.4)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
if !fallbackName.isEmpty {
|
||||||
|
Text(fallbackName)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.shadow(radius: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Placeholder trip detail — Phase 1 only.
|
||||||
|
/// Will be replaced with full itinerary/reservations/notes in Phase 2.
|
||||||
|
struct TripPlaceholderView: View {
|
||||||
|
let trip: Trip
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Hero image
|
||||||
|
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
||||||
|
.frame(height: 260)
|
||||||
|
.clipShape(.rect(cornerRadius: 20))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Trip info
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(trip.name)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
|
||||||
|
Text(trip.dateRange)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if !trip.tripLength.isEmpty {
|
||||||
|
Text(trip.tripLength)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coming soon
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "map.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(Color.accentWarm.opacity(0.3))
|
||||||
|
|
||||||
|
Text("Trip details coming soon")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
|
||||||
|
Text("Itinerary, reservations, notes, and more")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
.padding(.top, 40)
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationTitle(trip.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The main Trips home screen — inspired by Apple's Wishlist sample.
|
||||||
|
///
|
||||||
|
/// ViewModel owned by MainTabView (stable across navigation transitions).
|
||||||
|
/// Always renders the ScrollView — no conditional loading/content flip
|
||||||
|
/// that would destroy the namespace during transitions.
|
||||||
|
struct TripsHomeView: View {
|
||||||
|
@Bindable var vm: TripsViewModel
|
||||||
|
|
||||||
|
@Namespace private var namespace
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
if vm.isLoading && vm.trips.isEmpty {
|
||||||
|
// Loading inside the ScrollView — keeps view tree stable
|
||||||
|
VStack {
|
||||||
|
Spacer(minLength: 200)
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.regular)
|
||||||
|
Text("Loading trips...")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
Spacer(minLength: 200)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
UpcomingTripsPageView(trips: vm.upcomingTrips, namespace: namespace)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
|
PastTripsSection(trips: vm.pastTrips, namespace: namespace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentMargins(.bottom, 30, for: .scrollContent)
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.refreshable {
|
||||||
|
await vm.refresh()
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .title) {
|
||||||
|
HStack {
|
||||||
|
Text("Trips")
|
||||||
|
.font(.system(size: 34, weight: .medium))
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
.bold()
|
||||||
|
.fixedSize()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
// Phase 2: present plan trip sheet
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.glassProminent)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Trips")
|
||||||
|
.toolbarTitleDisplayMode(.inline)
|
||||||
|
.toolbarRole(.editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Displays upcoming trips in a paged TabView — the hero section.
|
||||||
|
/// Adapted from Apple's Wishlist RecentTripsPageView.
|
||||||
|
///
|
||||||
|
/// Namespace is passed from the parent (TripsHomeView) to ensure
|
||||||
|
/// stability across navigation transitions.
|
||||||
|
struct UpcomingTripsPageView: View {
|
||||||
|
let trips: [Trip]
|
||||||
|
var namespace: Namespace.ID
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if trips.isEmpty {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "airplane.departure")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(Color.accentWarm.opacity(0.4))
|
||||||
|
Text("No upcoming trips")
|
||||||
|
.font(.title3.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
Text("Tap + to plan your next adventure")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 60)
|
||||||
|
} else {
|
||||||
|
TabView {
|
||||||
|
ForEach(trips) { trip in
|
||||||
|
NavigationLink {
|
||||||
|
TripDetailView(trip: trip)
|
||||||
|
.navigationTransition(
|
||||||
|
.zoom(sourceID: trip.id, in: namespace))
|
||||||
|
} label: {
|
||||||
|
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("UPCOMING")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(Color.emerald)
|
||||||
|
|
||||||
|
Text(trip.name)
|
||||||
|
.font(.title)
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Text(trip.dateRange)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 54)
|
||||||
|
}
|
||||||
|
.matchedTransitionSource(id: trip.id, in: namespace)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page)
|
||||||
|
.containerRelativeFrame([.horizontal, .vertical]) { length, axis in
|
||||||
|
if axis == .vertical {
|
||||||
|
return length / 1.3
|
||||||
|
} else {
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,5 +6,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Take a photo of your meal to log it with AI</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
10
ios/Platform/Platform/Platform.entitlements
Normal file
10
ios/Platform/Platform/Platform.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.quadjourney.platform</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
39
ios/Platform/Platform/Shared/Components/CameraView.swift
Normal file
39
ios/Platform/Platform/Shared/Components/CameraView.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// UIImagePickerController wrapper for taking photos with the camera.
|
||||||
|
struct CameraView: UIViewControllerRepresentable {
|
||||||
|
let onCapture: (UIImage) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = .camera
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(onCapture: onCapture)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let onCapture: (UIImage) -> Void
|
||||||
|
|
||||||
|
init(onCapture: @escaping (UIImage) -> Void) {
|
||||||
|
self.onCapture = onCapture
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
|
if let image = info[.originalImage] as? UIImage {
|
||||||
|
onCapture(image)
|
||||||
|
}
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ struct MacroBar: View {
|
|||||||
var color: Color = .emerald
|
var color: Color = .emerald
|
||||||
var unit: String = "g"
|
var unit: String = "g"
|
||||||
|
|
||||||
private var progress: Double {
|
@State private var animatedProgress: Double = 0
|
||||||
|
|
||||||
|
private var targetProgress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(max(consumed / goal, 0), 1.0)
|
return min(max(consumed / goal, 0), 1.0)
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,7 @@ struct MacroBar: View {
|
|||||||
Text("\(Int(consumed))/\(Int(goal))\(unit)")
|
Text("\(Int(consumed))/\(Int(goal))\(unit)")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
}
|
}
|
||||||
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
@@ -32,11 +35,26 @@ struct MacroBar: View {
|
|||||||
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(color)
|
.fill(color)
|
||||||
.frame(width: geo.size.width * progress, height: 8)
|
.frame(width: geo.size.width * animatedProgress, height: 8)
|
||||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 8)
|
.frame(height: 8)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
animatedProgress = 0
|
||||||
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.7).delay(0.3)) {
|
||||||
|
animatedProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: consumed) { _, _ in
|
||||||
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||||
|
animatedProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: goal) { _, _ in
|
||||||
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||||
|
animatedProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ struct MacroRing: View {
|
|||||||
var color: Color = .emerald
|
var color: Color = .emerald
|
||||||
var size: CGFloat = 100
|
var size: CGFloat = 100
|
||||||
|
|
||||||
private var progress: Double {
|
@State private var animatedProgress: Double = 0
|
||||||
|
|
||||||
|
private var targetProgress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(max(consumed / goal, 0), 1.0)
|
return min(max(consumed / goal, 0), 1.0)
|
||||||
}
|
}
|
||||||
@@ -18,15 +20,30 @@ struct MacroRing: View {
|
|||||||
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: progress)
|
.trim(from: 0, to: animatedProgress)
|
||||||
.stroke(
|
.stroke(
|
||||||
color,
|
color,
|
||||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.easeInOut(duration: 0.6), value: progress)
|
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
|
.onAppear {
|
||||||
|
animatedProgress = 0
|
||||||
|
withAnimation(.spring(response: 1.0, dampingFraction: 0.7).delay(0.2)) {
|
||||||
|
animatedProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: consumed) { _, _ in
|
||||||
|
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||||
|
animatedProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: goal) { _, _ in
|
||||||
|
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||||
|
animatedProgress = targetProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +55,8 @@ struct MacroRingWithLabel: View {
|
|||||||
var size: CGFloat = 100
|
var size: CGFloat = 100
|
||||||
var lineWidth: CGFloat = 10
|
var lineWidth: CGFloat = 10
|
||||||
|
|
||||||
|
@State private var showValue = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
MacroRing(
|
MacroRing(
|
||||||
@@ -52,10 +71,19 @@ struct MacroRingWithLabel: View {
|
|||||||
Text("\(Int(consumed))")
|
Text("\(Int(consumed))")
|
||||||
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: size * 0.1, weight: .medium))
|
.font(.system(size: size * 0.1, weight: .medium))
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
|
.opacity(showValue ? 1 : 0)
|
||||||
|
.scaleEffect(showValue ? 1 : 0.5)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
showValue = false
|
||||||
|
withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.4)) {
|
||||||
|
showValue = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,45 +4,45 @@ extension Color {
|
|||||||
// MARK: - Canvas / Background (adaptive light/dark)
|
// MARK: - Canvas / Background (adaptive light/dark)
|
||||||
static let canvas = Color(UIColor { traits in
|
static let canvas = Color(UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark
|
traits.userInterfaceStyle == .dark
|
||||||
? UIColor(red: 0.10, green: 0.09, blue: 0.08, alpha: 1) // warm dark
|
? UIColor(red: 0.05, green: 0.05, blue: 0.045, alpha: 1) // #0d0d0b — near-black, less warm
|
||||||
: UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1) // #F5EFE6
|
: UIColor(red: 0.92, green: 0.90, blue: 0.87, alpha: 1) // #EBE6DE — neutral sand
|
||||||
})
|
})
|
||||||
|
|
||||||
// MARK: - Accent
|
// MARK: - Accent
|
||||||
static let accentWarm = Color(UIColor { traits in
|
static let accentWarm = Color(UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark
|
traits.userInterfaceStyle == .dark
|
||||||
? UIColor(red: 0.78, green: 0.62, blue: 0.25, alpha: 1) // brighter gold for dark
|
? UIColor(red: 0.82, green: 0.65, blue: 0.28, alpha: 1) // brighter gold for dark
|
||||||
: UIColor(red: 0.545, green: 0.412, blue: 0.078, alpha: 1) // #8B6914
|
: UIColor(red: 0.50, green: 0.37, blue: 0.06, alpha: 1) // #805E0F — slightly deeper
|
||||||
})
|
})
|
||||||
static let emerald = Color(red: 0.020, green: 0.588, blue: 0.412) // #059669
|
static let emerald = Color(red: 0.020, green: 0.588, blue: 0.412) // #059669
|
||||||
|
|
||||||
// MARK: - Surfaces (adaptive)
|
// MARK: - Surfaces (adaptive)
|
||||||
static let surfaceCard = Color(UIColor { traits in
|
static let surfaceCard = Color(UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark
|
traits.userInterfaceStyle == .dark
|
||||||
? UIColor(red: 0.15, green: 0.14, blue: 0.13, alpha: 1) // warm dark card
|
? UIColor(red: 0.11, green: 0.105, blue: 0.09, alpha: 1) // #1c1b17 — warm dark card
|
||||||
: UIColor(red: 1.0, green: 0.988, blue: 0.973, alpha: 1) // warm white
|
: UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1) // #FFFFFF — clean white
|
||||||
})
|
})
|
||||||
static let surfaceSheet = Color(UIColor { traits in
|
static let surfaceSheet = Color(UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark
|
traits.userInterfaceStyle == .dark
|
||||||
? UIColor(red: 0.13, green: 0.12, blue: 0.11, alpha: 1)
|
? UIColor(red: 0.12, green: 0.115, blue: 0.10, alpha: 1)
|
||||||
: UIColor(red: 0.98, green: 0.97, blue: 0.95, alpha: 1)
|
: UIColor(red: 0.97, green: 0.96, blue: 0.94, alpha: 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
// MARK: - Text (adaptive)
|
// MARK: - Text (adaptive — slightly more neutral for glass legibility)
|
||||||
static let textPrimary = Color(UIColor { traits in
|
static let textPrimary = Color(UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark
|
traits.userInterfaceStyle == .dark
|
||||||
? UIColor(red: 0.93, green: 0.91, blue: 0.88, alpha: 1)
|
? UIColor(red: 0.94, green: 0.92, blue: 0.90, alpha: 1)
|
||||||
: UIColor(red: 0.12, green: 0.12, blue: 0.12, alpha: 1)
|
: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1) // darker for contrast
|
||||||
})
|
})
|
||||||
static let textSecondary = Color(UIColor { traits in
|
static let textSecondary = Color(UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark
|
traits.userInterfaceStyle == .dark
|
||||||
? UIColor(red: 0.62, green: 0.60, blue: 0.57, alpha: 1)
|
? UIColor(red: 0.60, green: 0.58, blue: 0.55, alpha: 1)
|
||||||
: UIColor(red: 0.45, green: 0.45, blue: 0.45, alpha: 1)
|
: UIColor(red: 0.40, green: 0.40, blue: 0.40, alpha: 1) // more neutral gray
|
||||||
})
|
})
|
||||||
static let textTertiary = Color(UIColor { traits in
|
static let textTertiary = Color(UIColor { traits in
|
||||||
traits.userInterfaceStyle == .dark
|
traits.userInterfaceStyle == .dark
|
||||||
? UIColor(red: 0.45, green: 0.43, blue: 0.40, alpha: 1)
|
? UIColor(red: 0.43, green: 0.42, blue: 0.40, alpha: 1)
|
||||||
: UIColor(red: 0.65, green: 0.65, blue: 0.65, alpha: 1)
|
: UIColor(red: 0.58, green: 0.58, blue: 0.58, alpha: 1) // more neutral
|
||||||
})
|
})
|
||||||
|
|
||||||
// MARK: - Meal Colors
|
// MARK: - Meal Colors
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ios/Platform/PlatformWidget/Info.plist
Normal file
11
ios/Platform/PlatformWidget/Info.plist
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?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>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
329
ios/Platform/PlatformWidget/PlatformWidget.swift
Normal file
329
ios/Platform/PlatformWidget/PlatformWidget.swift
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - App Group shared storage
|
||||||
|
//
|
||||||
|
// Auth flow:
|
||||||
|
// 1. Main app logs in → gets session cookie from gateway
|
||||||
|
// 2. Main app stores cookie value in App Group UserDefaults
|
||||||
|
// 3. Widget reads cookie from App Group UserDefaults
|
||||||
|
// 4. Widget makes API calls with that cookie
|
||||||
|
//
|
||||||
|
// Data stored in App Group:
|
||||||
|
// - "widget_sessionCookie": String (the session=xxx cookie value)
|
||||||
|
// - "widget_totalCalories": Double (fallback cache)
|
||||||
|
// - "widget_calorieGoal": Double (fallback cache)
|
||||||
|
// - "widget_lastUpdate": Date (when cache was last written)
|
||||||
|
|
||||||
|
private let appGroup = "group.com.quadjourney.platform"
|
||||||
|
private let gatewayURL = "https://dash.quadjourney.com"
|
||||||
|
|
||||||
|
private var sharedDefaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: appGroup) ?? .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline Entry
|
||||||
|
|
||||||
|
struct CalorieEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let totalCalories: Double
|
||||||
|
let calorieGoal: Double
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard calorieGoal > 0 else { return 0 }
|
||||||
|
return min(max(totalCalories / calorieGoal, 0), 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining: Double {
|
||||||
|
max(calorieGoal - totalCalories, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let placeholder = CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline Provider
|
||||||
|
|
||||||
|
struct CalorieProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> CalorieEntry {
|
||||||
|
.placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (CalorieEntry) -> Void) {
|
||||||
|
if context.isPreview {
|
||||||
|
completion(.placeholder)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Return cached data for snapshot
|
||||||
|
completion(readCachedEntry())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<CalorieEntry>) -> Void) {
|
||||||
|
Task {
|
||||||
|
let entry: CalorieEntry
|
||||||
|
|
||||||
|
// Try fetching fresh data from API
|
||||||
|
if let fresh = await fetchFromAPI() {
|
||||||
|
entry = fresh
|
||||||
|
// Cache for fallback
|
||||||
|
sharedDefaults.set(fresh.totalCalories, forKey: "widget_totalCalories")
|
||||||
|
sharedDefaults.set(fresh.calorieGoal, forKey: "widget_calorieGoal")
|
||||||
|
sharedDefaults.set(Date(), forKey: "widget_lastUpdate")
|
||||||
|
} else {
|
||||||
|
// Network failed — use cached data
|
||||||
|
entry = readCachedEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh every 15 minutes (WidgetKit may throttle to ~every hour)
|
||||||
|
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
|
||||||
|
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||||
|
completion(timeline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API Fetch
|
||||||
|
|
||||||
|
private func fetchFromAPI() async -> CalorieEntry? {
|
||||||
|
guard let cookie = sharedDefaults.string(forKey: "widget_sessionCookie"),
|
||||||
|
!cookie.isEmpty else {
|
||||||
|
return nil // No auth — user hasn't logged in yet
|
||||||
|
}
|
||||||
|
|
||||||
|
let today = formatDate(.now)
|
||||||
|
|
||||||
|
async let totalsData = apiGet("/api/fitness/entries/totals?date=\(today)", cookie: cookie)
|
||||||
|
async let goalData = apiGet("/api/fitness/goals/for-date?date=\(today)", cookie: cookie)
|
||||||
|
|
||||||
|
guard let totals = await totalsData,
|
||||||
|
let goal = await goalData else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let calories = totals["total_calories"] as? Double
|
||||||
|
?? (totals["total_calories"] as? Int).map(Double.init)
|
||||||
|
?? 0
|
||||||
|
let goalCalories = goal["calories"] as? Double
|
||||||
|
?? (goal["calories"] as? Int).map(Double.init)
|
||||||
|
?? 2000
|
||||||
|
|
||||||
|
return CalorieEntry(date: .now, totalCalories: calories, calorieGoal: goalCalories)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apiGet(_ path: String, cookie: String) async -> [String: Any]? {
|
||||||
|
guard let url = URL(string: gatewayURL + path) else { return nil }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("session=\(cookie)", forHTTPHeaderField: "Cookie")
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else { return nil }
|
||||||
|
|
||||||
|
// Session expired — clear stale cookie so we don't retry
|
||||||
|
if http.statusCode == 401 {
|
||||||
|
sharedDefaults.removeObject(forKey: "widget_sessionCookie")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(http.statusCode) else { return nil }
|
||||||
|
return try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readCachedEntry() -> CalorieEntry {
|
||||||
|
let calories = sharedDefaults.double(forKey: "widget_totalCalories")
|
||||||
|
let goal = sharedDefaults.double(forKey: "widget_calorieGoal")
|
||||||
|
return CalorieEntry(
|
||||||
|
date: .now,
|
||||||
|
totalCalories: calories,
|
||||||
|
calorieGoal: goal > 0 ? goal : 2000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ date: Date) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Views
|
||||||
|
|
||||||
|
struct CalorieRingView: View {
|
||||||
|
let entry: CalorieEntry
|
||||||
|
let size: CGFloat
|
||||||
|
let lineWidth: CGFloat
|
||||||
|
|
||||||
|
private let ringColor = Color(red: 0.020, green: 0.588, blue: 0.412)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(ringColor.opacity(0.2), lineWidth: lineWidth)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: entry.progress)
|
||||||
|
.stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
Text("\(Int(entry.totalCalories))")
|
||||||
|
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("/ \(Int(entry.calorieGoal))")
|
||||||
|
.font(.system(size: size * 0.1, weight: .medium, design: .rounded))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SmallWidgetView: View {
|
||||||
|
let entry: CalorieEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
CalorieRingView(entry: entry, size: 80, lineWidth: 7)
|
||||||
|
|
||||||
|
Text("\(Int(entry.remaining)) left")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
// + button — separate tap target → opens food assistant
|
||||||
|
Link(destination: URL(string: "platform://add-food")!) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color(red: 0.020, green: 0.588, blue: 0.412))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MediumWidgetView: View {
|
||||||
|
let entry: CalorieEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
CalorieRingView(entry: entry, size: 100, lineWidth: 9)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Calories")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Text("\(Int(entry.totalCalories)) of \(Int(entry.calorieGoal))")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("\(Int(entry.remaining)) remaining")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
Link(destination: URL(string: "platform://add-food")!) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color(red: 0.020, green: 0.588, blue: 0.412))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CircularWidgetView: View {
|
||||||
|
let entry: CalorieEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Gauge(value: entry.progress) {
|
||||||
|
Text("\(Int(entry.totalCalories))")
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircular)
|
||||||
|
.tint(Color(red: 0.020, green: 0.588, blue: 0.412))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InlineWidgetView: View {
|
||||||
|
let entry: CalorieEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("\(Int(entry.totalCalories)) / \(Int(entry.calorieGoal)) cal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RectangularWidgetView: View {
|
||||||
|
let entry: CalorieEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Gauge(value: entry.progress) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryLinear)
|
||||||
|
.tint(Color(red: 0.020, green: 0.588, blue: 0.412))
|
||||||
|
|
||||||
|
Text("\(Int(entry.totalCalories)) cal")
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry View (family-aware)
|
||||||
|
|
||||||
|
struct PlatformWidgetEntryView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: CalorieEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .systemSmall:
|
||||||
|
SmallWidgetView(entry: entry)
|
||||||
|
case .systemMedium:
|
||||||
|
MediumWidgetView(entry: entry)
|
||||||
|
case .accessoryCircular:
|
||||||
|
CircularWidgetView(entry: entry)
|
||||||
|
case .accessoryInline:
|
||||||
|
InlineWidgetView(entry: entry)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
RectangularWidgetView(entry: entry)
|
||||||
|
default:
|
||||||
|
SmallWidgetView(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Configuration
|
||||||
|
|
||||||
|
struct PlatformWidget: Widget {
|
||||||
|
let kind: String = "PlatformWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalorieProvider()) { entry in
|
||||||
|
PlatformWidgetEntryView(entry: entry)
|
||||||
|
.widgetURL(URL(string: "platform://fitness"))
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Calories")
|
||||||
|
.description("Today's calorie progress ring.")
|
||||||
|
.supportedFamilies([
|
||||||
|
.systemSmall,
|
||||||
|
.systemMedium,
|
||||||
|
.accessoryCircular,
|
||||||
|
.accessoryInline,
|
||||||
|
.accessoryRectangular,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview(as: .systemSmall) {
|
||||||
|
PlatformWidget()
|
||||||
|
} timeline: {
|
||||||
|
CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
|
||||||
|
}
|
||||||
9
ios/Platform/PlatformWidget/PlatformWidgetBundle.swift
Normal file
9
ios/Platform/PlatformWidget/PlatformWidgetBundle.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PlatformWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
PlatformWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.quadjourney.platform</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Reference in New Issue
Block a user