Compare commits
53 Commits
8a8f865702
...
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 |
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 */; };
|
||||
A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.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 */; };
|
||||
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.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 */; };
|
||||
A10048 /* ScrollViewDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10048 /* ScrollViewDriver.swift */; };
|
||||
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 */
|
||||
|
||||
/* 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 */
|
||||
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>"; };
|
||||
@@ -62,6 +101,17 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -100,7 +150,14 @@
|
||||
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>"; };
|
||||
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; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -112,6 +169,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
W10060 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -119,6 +183,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F10002 /* Platform */,
|
||||
W10070 /* PlatformWidget */,
|
||||
F10020 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -126,6 +191,7 @@
|
||||
F10002 /* Platform */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B10090 /* Platform.entitlements */,
|
||||
B10001 /* PlatformApp.swift */,
|
||||
B10002 /* ContentView.swift */,
|
||||
B10003 /* Config.swift */,
|
||||
@@ -157,6 +223,7 @@
|
||||
F10014 /* Assistant */,
|
||||
F10021 /* Feedback */,
|
||||
F10030 /* Reader */,
|
||||
F10050 /* Trips */,
|
||||
);
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
@@ -266,6 +333,7 @@
|
||||
B10027 /* MacroRing.swift */,
|
||||
B10028 /* MacroBar.swift */,
|
||||
B10029 /* LoadingView.swift */,
|
||||
B10070 /* CameraView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
@@ -283,10 +351,23 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D10001 /* Platform.app */,
|
||||
W10020 /* PlatformWidgetExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -343,6 +424,55 @@
|
||||
path = Views;
|
||||
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 */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -353,18 +483,45 @@
|
||||
G10002 /* Sources */,
|
||||
E10001 /* Frameworks */,
|
||||
G10003 /* Resources */,
|
||||
W10050 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
W10031 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Platform;
|
||||
productName = Platform;
|
||||
productReference = D10001 /* Platform.app */;
|
||||
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 */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
W10031 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = W10040 /* PlatformWidgetExtension */;
|
||||
targetProxy = W10030 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
G10010 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
@@ -376,6 +533,9 @@
|
||||
G10001 = {
|
||||
CreatedOnToolsVersion = 15.4;
|
||||
};
|
||||
W10040 = {
|
||||
CreatedOnToolsVersion = 15.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = H10001 /* Build configuration list for PBXProject "Platform" */;
|
||||
@@ -395,6 +555,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
G10001 /* Platform */,
|
||||
W10040 /* PlatformWidgetExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -408,6 +569,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
W10042 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
W10003 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -422,6 +591,17 @@
|
||||
A10005 /* AuthManager.swift in Sources */,
|
||||
A10050 /* AppearanceManager.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 */,
|
||||
A10007 /* HomeView.swift in Sources */,
|
||||
A10008 /* HomeViewModel.swift in Sources */,
|
||||
@@ -461,6 +641,15 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
W10041 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
W10001 /* PlatformWidgetBundle.swift in Sources */,
|
||||
W10002 /* PlatformWidget.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -588,6 +777,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Platform/Platform.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||
@@ -617,6 +807,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Platform/Platform.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||
@@ -641,6 +832,62 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -662,6 +909,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
W10080 /* Build configuration list for PBXNativeTarget "PlatformWidgetExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
W10081 /* Debug */,
|
||||
W10082 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList 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 confettiTrigger = 0
|
||||
@State private var readerVM = ReaderViewModel()
|
||||
@State private var tripsVM = TripsViewModel()
|
||||
@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 {
|
||||
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 {
|
||||
ZStack {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -51,89 +81,34 @@ struct MainTabView: View {
|
||||
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)
|
||||
.tabBarMinimizeBehavior(.onScrollDown)
|
||||
|
||||
// Floating action button — context-dependent
|
||||
// Feedback button (not on Reader)
|
||||
if selectedTab != 2 {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(alignment: .bottom) {
|
||||
if selectedTab != 2 {
|
||||
HStack {
|
||||
FeedbackButton()
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -150,17 +125,58 @@ struct MainTabView: View {
|
||||
.sheet(isPresented: $showAssistant) {
|
||||
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 {
|
||||
// Pre-load trips for all users
|
||||
await tripsVM.loadTrips()
|
||||
|
||||
// Reader only for non-Madiha
|
||||
guard showReader else { return }
|
||||
let renderer = ArticleRenderer.shared
|
||||
renderer.attachToWindow()
|
||||
await readerVM.loadInitial()
|
||||
}
|
||||
.onChange(of: selectedTab) { _, newTab in
|
||||
// Stop auto-scroll when leaving Reader
|
||||
.onChange(of: selectedTab) { oldTab, newTab in
|
||||
if newTab == 3 {
|
||||
// Action tab tapped — handle based on previous tab
|
||||
handleActionTap(from: oldTab)
|
||||
} else {
|
||||
previousTab = newTab
|
||||
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() {
|
||||
showAssistant = false
|
||||
@@ -188,9 +204,10 @@ struct AssistantSheetView: View {
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
|
||||
// Pill selector — tappable, syncs with swipe
|
||||
HStack(spacing: 4) {
|
||||
tabButton("AI Chat", icon: "sparkles", index: 0)
|
||||
tabButton("Quick Add", icon: "magnifyingglass", index: 1)
|
||||
tabPill("Quick Add", icon: "magnifyingglass", index: 0)
|
||||
tabPill("AI Chat", icon: "sparkles", index: 1)
|
||||
}
|
||||
.padding(4)
|
||||
.background(Color.textTertiary.opacity(0.08))
|
||||
@@ -200,17 +217,22 @@ struct AssistantSheetView: View {
|
||||
.padding(.bottom, 12)
|
||||
.background(Color.canvas)
|
||||
|
||||
if selectedMode == 0 {
|
||||
AssistantChatView(onFoodAdded: onFoodAdded)
|
||||
} else {
|
||||
// Swipeable pages — Quick Add first
|
||||
TabView(selection: $selectedMode) {
|
||||
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)
|
||||
.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 {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index }
|
||||
} label: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
@Observable
|
||||
final class AuthManager {
|
||||
@@ -10,6 +11,12 @@ final class AuthManager {
|
||||
private let api = APIClient.shared
|
||||
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() {
|
||||
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
|
||||
}
|
||||
@@ -46,9 +53,11 @@ final class AuthManager {
|
||||
if response.authenticated, let user = response.user {
|
||||
currentUser = user
|
||||
isLoggedIn = true
|
||||
syncCookieToWidget()
|
||||
} else {
|
||||
isLoggedIn = false
|
||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||
clearWidgetAuth()
|
||||
}
|
||||
} catch {
|
||||
isLoggedIn = false
|
||||
@@ -68,6 +77,9 @@ final class AuthManager {
|
||||
currentUser = response.user
|
||||
isLoggedIn = true
|
||||
UserDefaults.standard.set(true, forKey: loggedInKey)
|
||||
clearWidgetAuth() // Clear previous user's cached data
|
||||
syncCookieToWidget()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
} catch let apiError as APIError {
|
||||
error = apiError.localizedDescription
|
||||
@@ -83,5 +95,26 @@ final class AuthManager {
|
||||
currentUser = nil
|
||||
isLoggedIn = false
|
||||
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 {
|
||||
@State private var vm = AssistantViewModel()
|
||||
@State private var showCamera = false
|
||||
@State private var showPhotoPicker = false
|
||||
var onFoodAdded: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
@@ -83,19 +85,32 @@ struct AssistantChatView: View {
|
||||
Divider()
|
||||
|
||||
// Input bar
|
||||
HStack(spacing: 10) {
|
||||
PhotosPicker(selection: $vm.selectedPhoto, matching: .images) {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
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")
|
||||
.font(.title3)
|
||||
.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)
|
||||
.onSubmit {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
Task { await vm.send() }
|
||||
}
|
||||
.lineLimit(1...5)
|
||||
|
||||
Button {
|
||||
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)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.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
|
||||
|
||||
@@ -173,6 +173,23 @@ final class AssistantViewModel {
|
||||
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
|
||||
|
||||
private func draftToDict(_ draft: FitnessDraft) -> [String: Any] {
|
||||
|
||||
@@ -3,6 +3,13 @@ import SwiftUI
|
||||
struct EditableDraftCard: View {
|
||||
@Bindable var vm: AssistantViewModel
|
||||
@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 }
|
||||
|
||||
@@ -135,8 +142,34 @@ struct EditableDraftCard: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
|
||||
.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
|
||||
|
||||
@@ -196,7 +229,10 @@ struct EditableDraftCard: View {
|
||||
private var quantityBinding: Binding<String> {
|
||||
Binding(
|
||||
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)")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
struct SuccessResponse: Decodable {
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct FoodLibraryView: View {
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: Food?
|
||||
@State private var editingFood: Food?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -76,6 +77,21 @@ struct FoodLibraryView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -92,5 +108,133 @@ struct FoodLibraryView: View {
|
||||
.sheet(item: $selectedFood) { food in
|
||||
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)?
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: Food?
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -15,6 +16,7 @@ struct FoodSearchView: View {
|
||||
TextField("Search foods...", text: $vm.searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.focused($searchFocused)
|
||||
if !vm.searchText.isEmpty {
|
||||
Button {
|
||||
vm.searchText = ""
|
||||
@@ -44,6 +46,11 @@ struct FoodSearchView: View {
|
||||
.background(Color.canvas)
|
||||
.task {
|
||||
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) {
|
||||
vm.search()
|
||||
|
||||
@@ -2,17 +2,14 @@ import SwiftUI
|
||||
|
||||
struct TodayView: View {
|
||||
@State private var vm = TodayViewModel()
|
||||
@State private var animated = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Date selector
|
||||
dateSelector
|
||||
|
||||
// Macro summary
|
||||
macroSummary
|
||||
|
||||
// Meal sections
|
||||
if vm.entries.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "fork.knife",
|
||||
@@ -20,7 +17,8 @@ struct TodayView: View {
|
||||
subtitle: "Tap + to log your first meal"
|
||||
)
|
||||
} 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(
|
||||
mealType: mealType,
|
||||
entries: entries,
|
||||
@@ -28,6 +26,9 @@ struct TodayView: View {
|
||||
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)
|
||||
.task {
|
||||
await vm.load()
|
||||
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
|
||||
animated = true
|
||||
}
|
||||
}
|
||||
.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 PhotosUI
|
||||
import WidgetKit
|
||||
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
@@ -32,6 +33,13 @@ final class HomeViewModel {
|
||||
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
|
||||
calorieGoal = repo.goal?.calories ?? 2000
|
||||
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
|
||||
|
||||
@@ -72,7 +72,6 @@ final class ReaderViewModel {
|
||||
if reset {
|
||||
offset = 0
|
||||
hasMore = true
|
||||
// DO NOT set entries = [] — causes full list teardown + empty flash
|
||||
}
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
@@ -80,7 +79,7 @@ final class ReaderViewModel {
|
||||
|
||||
do {
|
||||
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
|
||||
total = list.total
|
||||
offset = list.entries.count
|
||||
@@ -96,11 +95,21 @@ final class ReaderViewModel {
|
||||
isLoadingMore = true
|
||||
|
||||
do {
|
||||
let list = try await fetchEntries(offset: offset)
|
||||
entries.append(contentsOf: list.entries)
|
||||
// Use entries.count as offset — accounts for deduplication and
|
||||
// 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
|
||||
offset += list.entries.count
|
||||
hasMore = offset < list.total
|
||||
offset = entries.count
|
||||
hasMore = newEntries.count > 0
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
|
||||
// Only reload if content meaningfully changed
|
||||
guard context.coordinator.lastHTML != newHTML else { return }
|
||||
|
||||
let isUpgrade = context.coordinator.lastHTML != nil
|
||||
context.coordinator.lastHTML = newHTML
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ struct EntryListView: View {
|
||||
@State private var cumulativeDown: CGFloat = 0
|
||||
@State private var cumulativeUp: CGFloat = 0
|
||||
@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.
|
||||
private var viewportHeight: CGFloat {
|
||||
@@ -39,7 +40,9 @@ struct EntryListView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
// 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)
|
||||
|
||||
if isCardView {
|
||||
@@ -59,7 +62,15 @@ struct EntryListView: View {
|
||||
// Stop auto-scroll on navigation return
|
||||
isAutoScrolling = false
|
||||
}
|
||||
.onChange(of: isAutoScrolling) { _, scrolling in
|
||||
if !scrolling {
|
||||
flushDeferredReads()
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
// Flush any deferred read marks before refreshing
|
||||
flushDeferredReads()
|
||||
markedByScroll.removeAll()
|
||||
await vm.refresh()
|
||||
}
|
||||
.navigationDestination(for: ReaderEntry.self) { entry in
|
||||
@@ -113,10 +124,15 @@ struct EntryListView: View {
|
||||
|
||||
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 }) {
|
||||
vm.entries[idx].status = "read"
|
||||
}
|
||||
|
||||
Task {
|
||||
let api = ReaderAPI()
|
||||
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
|
||||
|
||||
private var cardLayout: some View {
|
||||
LazyVStack(spacing: 12) {
|
||||
LazyVStack(spacing: 14) {
|
||||
ForEach(vm.entries) { entry in
|
||||
scrollTracked(entry,
|
||||
content: NavigationLink(value: entry) {
|
||||
@@ -142,6 +181,7 @@ struct EntryListView: View {
|
||||
entryContextMenu(entry: entry, vm: vm)
|
||||
}
|
||||
)
|
||||
.onAppear { loadMoreIfNeeded(for: entry) }
|
||||
}
|
||||
|
||||
loadMoreTrigger
|
||||
@@ -149,7 +189,7 @@ struct EntryListView: View {
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - List Layout
|
||||
@@ -167,6 +207,7 @@ struct EntryListView: View {
|
||||
entryContextMenu(entry: entry, vm: vm)
|
||||
}
|
||||
)
|
||||
.onAppear { loadMoreIfNeeded(for: entry) }
|
||||
|
||||
Divider()
|
||||
.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 {
|
||||
Group {
|
||||
if vm.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
} else if vm.entries.count > 0 {
|
||||
// Fallback trigger at the very bottom
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.frame(height: 40)
|
||||
.onAppear {
|
||||
Task { await vm.loadMore() }
|
||||
}
|
||||
@@ -205,9 +257,12 @@ struct EntryCardView: View {
|
||||
AsyncImage(url: thumbURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
GeometryReader { geo in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geo.size.width, height: 180)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.clipped()
|
||||
default:
|
||||
@@ -270,7 +325,7 @@ struct EntryCardView: View {
|
||||
}
|
||||
.background(Color.surfaceCard)
|
||||
.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
|
||||
@Binding var isAutoScrolling: Bool
|
||||
@Binding var scrollSpeed: Double
|
||||
@State private var selectedSubTab = 0
|
||||
@State private var showFeedSheet = false
|
||||
@State private var showFeedManagement = false
|
||||
@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 {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Sub-tab selector
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedSubTab = index
|
||||
switch index {
|
||||
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) {
|
||||
// Entry list as the main content — scrolls under the glass nav bar
|
||||
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Reader")
|
||||
.navigationSubtitle(subtitleText)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isCardView.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
|
||||
Menu {
|
||||
@@ -95,51 +61,9 @@ struct ReaderTabView: View {
|
||||
}
|
||||
} label: {
|
||||
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) {
|
||||
AddFeedSheet(vm: vm)
|
||||
}
|
||||
@@ -150,46 +74,8 @@ struct ReaderTabView: View {
|
||||
.onAppear {
|
||||
ArticleRenderer.shared.reWarmIfNeeded()
|
||||
}
|
||||
.onChange(of: selectedSubTab) { _, _ in
|
||||
isAutoScrolling = false
|
||||
}
|
||||
.onChange(of: vm.currentFilter) { _, _ in
|
||||
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 {
|
||||
@Binding var isScrolling: Bool
|
||||
let speed: Double // 1.0 = 60pt/sec
|
||||
var onNearBottom: (() -> Void)? = nil
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = DriverView()
|
||||
@@ -21,6 +22,7 @@ struct ScrollViewDriver: UIViewRepresentable {
|
||||
let coordinator = context.coordinator
|
||||
coordinator.speed = speed
|
||||
coordinator.isScrollingBinding = $isScrolling
|
||||
coordinator.onNearBottom = onNearBottom
|
||||
|
||||
if isScrolling && coordinator.displayLink == nil {
|
||||
coordinator.startScrolling(in: driver)
|
||||
@@ -54,8 +56,10 @@ struct ScrollViewDriver: UIViewRepresentable {
|
||||
var displayLink: CADisplayLink?
|
||||
var speed: Double = 1.0
|
||||
var isScrollingBinding: Binding<Bool>?
|
||||
var onNearBottom: (() -> Void)?
|
||||
private var originalDelegate: UIScrollViewDelegate?
|
||||
private var delegateInstalled = false
|
||||
private var loadMoreTriggered = false
|
||||
|
||||
func findScrollView(from view: UIView) {
|
||||
var current: UIView? = view.superview
|
||||
@@ -78,7 +82,6 @@ struct ScrollViewDriver: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func startScrolling(in view: UIView) {
|
||||
// Re-find scroll view if needed
|
||||
if scrollView == nil {
|
||||
findScrollView(from: view)
|
||||
}
|
||||
@@ -104,20 +107,29 @@ struct ScrollViewDriver: UIViewRepresentable {
|
||||
let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom
|
||||
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 newY = min(sv.contentOffset.y + delta, maxOffset)
|
||||
|
||||
sv.contentOffset.y = newY
|
||||
|
||||
// Notify delegate so tab bar minimize behavior triggers
|
||||
originalDelegate?.scrollViewDidScroll?(sv)
|
||||
|
||||
// Stop at bottom
|
||||
if newY >= maxOffset - 1 {
|
||||
stopAndNotify()
|
||||
// Trigger load more when within 500pt of bottom
|
||||
let distanceToBottom = maxOffset - newY
|
||||
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() {
|
||||
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/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Take a photo of your meal to log it with AI</string>
|
||||
</dict>
|
||||
</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 unit: String = "g"
|
||||
|
||||
private var progress: Double {
|
||||
@State private var animatedProgress: Double = 0
|
||||
|
||||
private var targetProgress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1.0)
|
||||
}
|
||||
@@ -22,6 +24,7 @@ struct MacroBar: View {
|
||||
Text("\(Int(consumed))/\(Int(goal))\(unit)")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
@@ -32,11 +35,26 @@ struct MacroBar: View {
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(color)
|
||||
.frame(width: geo.size.width * progress, height: 8)
|
||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
||||
.frame(width: geo.size.width * animatedProgress, 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 size: CGFloat = 100
|
||||
|
||||
private var progress: Double {
|
||||
@State private var animatedProgress: Double = 0
|
||||
|
||||
private var targetProgress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1.0)
|
||||
}
|
||||
@@ -18,15 +20,30 @@ struct MacroRing: View {
|
||||
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.trim(from: 0, to: animatedProgress)
|
||||
.stroke(
|
||||
color,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.6), value: progress)
|
||||
}
|
||||
.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 lineWidth: CGFloat = 10
|
||||
|
||||
@State private var showValue = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
MacroRing(
|
||||
@@ -52,10 +71,19 @@ struct MacroRingWithLabel: View {
|
||||
Text("\(Int(consumed))")
|
||||
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
Text(label)
|
||||
.font(.system(size: size * 0.1, weight: .medium))
|
||||
.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)
|
||||
static let canvas = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.10, green: 0.09, blue: 0.08, alpha: 1) // warm dark
|
||||
: UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1) // #F5EFE6
|
||||
? UIColor(red: 0.05, green: 0.05, blue: 0.045, alpha: 1) // #0d0d0b — near-black, less warm
|
||||
: UIColor(red: 0.92, green: 0.90, blue: 0.87, alpha: 1) // #EBE6DE — neutral sand
|
||||
})
|
||||
|
||||
// MARK: - Accent
|
||||
static let accentWarm = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.78, green: 0.62, blue: 0.25, alpha: 1) // brighter gold for dark
|
||||
: UIColor(red: 0.545, green: 0.412, blue: 0.078, alpha: 1) // #8B6914
|
||||
? UIColor(red: 0.82, green: 0.65, blue: 0.28, alpha: 1) // brighter gold for dark
|
||||
: 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
|
||||
|
||||
// MARK: - Surfaces (adaptive)
|
||||
static let surfaceCard = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.15, green: 0.14, blue: 0.13, alpha: 1) // warm dark card
|
||||
: UIColor(red: 1.0, green: 0.988, blue: 0.973, alpha: 1) // warm white
|
||||
? UIColor(red: 0.11, green: 0.105, blue: 0.09, alpha: 1) // #1c1b17 — warm dark card
|
||||
: UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1) // #FFFFFF — clean white
|
||||
})
|
||||
static let surfaceSheet = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.13, green: 0.12, blue: 0.11, alpha: 1)
|
||||
: UIColor(red: 0.98, green: 0.97, blue: 0.95, alpha: 1)
|
||||
? UIColor(red: 0.12, green: 0.115, blue: 0.10, 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
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.93, green: 0.91, blue: 0.88, alpha: 1)
|
||||
: UIColor(red: 0.12, green: 0.12, blue: 0.12, alpha: 1)
|
||||
? UIColor(red: 0.94, green: 0.92, blue: 0.90, alpha: 1)
|
||||
: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1) // darker for contrast
|
||||
})
|
||||
static let textSecondary = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.62, green: 0.60, blue: 0.57, alpha: 1)
|
||||
: UIColor(red: 0.45, green: 0.45, blue: 0.45, alpha: 1)
|
||||
? UIColor(red: 0.60, green: 0.58, blue: 0.55, alpha: 1)
|
||||
: UIColor(red: 0.40, green: 0.40, blue: 0.40, alpha: 1) // more neutral gray
|
||||
})
|
||||
static let textTertiary = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.45, green: 0.43, blue: 0.40, alpha: 1)
|
||||
: UIColor(red: 0.65, green: 0.65, blue: 0.65, alpha: 1)
|
||||
? UIColor(red: 0.43, green: 0.42, blue: 0.40, alpha: 1)
|
||||
: UIColor(red: 0.58, green: 0.58, blue: 0.58, alpha: 1) // more neutral
|
||||
})
|
||||
|
||||
// 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