restore: original UI views from first build, keep fixed models/API
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
555
ios/Platform 1.44.16 AM/Platform.xcodeproj/project.pbxproj
Normal file
555
ios/Platform 1.44.16 AM/Platform.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
A10001 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10001 /* PlatformApp.swift */; };
|
||||||
|
A10002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10002 /* ContentView.swift */; };
|
||||||
|
A10003 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10003 /* Config.swift */; };
|
||||||
|
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004 /* APIClient.swift */; };
|
||||||
|
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.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 */; };
|
||||||
|
A10009 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10009 /* FitnessModels.swift */; };
|
||||||
|
A10010 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10010 /* FitnessAPI.swift */; };
|
||||||
|
A10011 /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10011 /* FitnessRepository.swift */; };
|
||||||
|
A10012 /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10012 /* FitnessTabView.swift */; };
|
||||||
|
A10013 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10013 /* TodayView.swift */; };
|
||||||
|
A10014 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10014 /* MealSectionView.swift */; };
|
||||||
|
A10015 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10015 /* FoodSearchView.swift */; };
|
||||||
|
A10016 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10016 /* AddFoodSheet.swift */; };
|
||||||
|
A10017 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10017 /* HistoryView.swift */; };
|
||||||
|
A10018 /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10018 /* TemplatesView.swift */; };
|
||||||
|
A10019 /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10019 /* GoalsView.swift */; };
|
||||||
|
A10020 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10020 /* EntryDetailView.swift */; };
|
||||||
|
A10021 /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10021 /* TodayViewModel.swift */; };
|
||||||
|
A10022 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10022 /* FoodSearchViewModel.swift */; };
|
||||||
|
A10023 /* HistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10023 /* HistoryViewModel.swift */; };
|
||||||
|
A10024 /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10024 /* TemplatesViewModel.swift */; };
|
||||||
|
A10025 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10025 /* GoalsViewModel.swift */; };
|
||||||
|
A10026 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10026 /* MacroRing.swift */; };
|
||||||
|
A10027 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10027 /* MacroBar.swift */; };
|
||||||
|
A10028 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10028 /* LoadingView.swift */; };
|
||||||
|
A10029 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10029 /* Date+Extensions.swift */; };
|
||||||
|
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030 /* Color+Extensions.swift */; };
|
||||||
|
A10031 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10031 /* Assets.xcassets */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
B10000 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
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>"; };
|
||||||
|
B10003 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
|
||||||
|
B10004 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
|
B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.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>"; };
|
||||||
|
B10009 /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessModels.swift; sourceTree = "<group>"; };
|
||||||
|
B10010 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessAPI.swift; sourceTree = "<group>"; };
|
||||||
|
B10011 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessRepository.swift; sourceTree = "<group>"; };
|
||||||
|
B10012 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessTabView.swift; sourceTree = "<group>"; };
|
||||||
|
B10013 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
||||||
|
B10014 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSectionView.swift; sourceTree = "<group>"; };
|
||||||
|
B10015 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchView.swift; sourceTree = "<group>"; };
|
||||||
|
B10016 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFoodSheet.swift; sourceTree = "<group>"; };
|
||||||
|
B10017 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
|
B10018 /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesView.swift; sourceTree = "<group>"; };
|
||||||
|
B10019 /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsView.swift; sourceTree = "<group>"; };
|
||||||
|
B10020 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
B10021 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B10022 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B10023 /* HistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B10024 /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B10025 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B10026 /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroRing.swift; sourceTree = "<group>"; };
|
||||||
|
B10027 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroBar.swift; sourceTree = "<group>"; };
|
||||||
|
B10028 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
||||||
|
B10029 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
B10031 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
C10001 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
G10000 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
G10001 /* Platform */,
|
||||||
|
G10099 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10001 /* Platform */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10001 /* PlatformApp.swift */,
|
||||||
|
B10002 /* ContentView.swift */,
|
||||||
|
B10003 /* Config.swift */,
|
||||||
|
B10031 /* Assets.xcassets */,
|
||||||
|
G10002 /* Core */,
|
||||||
|
G10003 /* Features */,
|
||||||
|
G10004 /* Shared */,
|
||||||
|
);
|
||||||
|
path = Platform;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10002 /* Core */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10004 /* APIClient.swift */,
|
||||||
|
B10005 /* AuthManager.swift */,
|
||||||
|
);
|
||||||
|
path = Core;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10003 /* Features */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
G10010 /* Auth */,
|
||||||
|
G10011 /* Home */,
|
||||||
|
G10012 /* Fitness */,
|
||||||
|
);
|
||||||
|
path = Features;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10004 /* Shared */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
G10005 /* Components */,
|
||||||
|
G10006 /* Extensions */,
|
||||||
|
);
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10005 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10026 /* MacroRing.swift */,
|
||||||
|
B10027 /* MacroBar.swift */,
|
||||||
|
B10028 /* LoadingView.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10006 /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10029 /* Date+Extensions.swift */,
|
||||||
|
B10030 /* Color+Extensions.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10010 /* Auth */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10006 /* LoginView.swift */,
|
||||||
|
);
|
||||||
|
path = Auth;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10011 /* Home */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10007 /* HomeView.swift */,
|
||||||
|
B10008 /* HomeViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = Home;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10012 /* Fitness */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
G10013 /* Models */,
|
||||||
|
G10014 /* API */,
|
||||||
|
G10015 /* Repository */,
|
||||||
|
G10016 /* Views */,
|
||||||
|
G10017 /* ViewModels */,
|
||||||
|
);
|
||||||
|
path = Fitness;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10013 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10009 /* FitnessModels.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10014 /* API */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10010 /* FitnessAPI.swift */,
|
||||||
|
);
|
||||||
|
path = API;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10015 /* Repository */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10011 /* FitnessRepository.swift */,
|
||||||
|
);
|
||||||
|
path = Repository;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10016 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10012 /* FitnessTabView.swift */,
|
||||||
|
B10013 /* TodayView.swift */,
|
||||||
|
B10014 /* MealSectionView.swift */,
|
||||||
|
B10015 /* FoodSearchView.swift */,
|
||||||
|
B10016 /* AddFoodSheet.swift */,
|
||||||
|
B10017 /* HistoryView.swift */,
|
||||||
|
B10018 /* TemplatesView.swift */,
|
||||||
|
B10019 /* GoalsView.swift */,
|
||||||
|
B10020 /* EntryDetailView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10017 /* ViewModels */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10021 /* TodayViewModel.swift */,
|
||||||
|
B10022 /* FoodSearchViewModel.swift */,
|
||||||
|
B10023 /* HistoryViewModel.swift */,
|
||||||
|
B10024 /* TemplatesViewModel.swift */,
|
||||||
|
B10025 /* GoalsViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = ViewModels;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
G10099 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10000 /* Platform.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
T10001 /* Platform */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = CL10002 /* Build configuration list for PBXNativeTarget "Platform" */;
|
||||||
|
buildPhases = (
|
||||||
|
S10001 /* Sources */,
|
||||||
|
C10001 /* Frameworks */,
|
||||||
|
R10001 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = Platform;
|
||||||
|
productName = Platform;
|
||||||
|
productReference = B10000 /* Platform.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
P10001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1540;
|
||||||
|
LastUpgradeCheck = 1540;
|
||||||
|
TargetAttributes = {
|
||||||
|
T10001 = {
|
||||||
|
CreatedOnToolsVersion = 15.4;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = CL10001 /* Build configuration list for PBXProject "Platform" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = G10000;
|
||||||
|
productRefGroup = G10099 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
T10001 /* Platform */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
R10001 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A10031 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
S10001 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A10001 /* PlatformApp.swift in Sources */,
|
||||||
|
A10002 /* ContentView.swift in Sources */,
|
||||||
|
A10003 /* Config.swift in Sources */,
|
||||||
|
A10004 /* APIClient.swift in Sources */,
|
||||||
|
A10005 /* AuthManager.swift in Sources */,
|
||||||
|
A10006 /* LoginView.swift in Sources */,
|
||||||
|
A10007 /* HomeView.swift in Sources */,
|
||||||
|
A10008 /* HomeViewModel.swift in Sources */,
|
||||||
|
A10009 /* FitnessModels.swift in Sources */,
|
||||||
|
A10010 /* FitnessAPI.swift in Sources */,
|
||||||
|
A10011 /* FitnessRepository.swift in Sources */,
|
||||||
|
A10012 /* FitnessTabView.swift in Sources */,
|
||||||
|
A10013 /* TodayView.swift in Sources */,
|
||||||
|
A10014 /* MealSectionView.swift in Sources */,
|
||||||
|
A10015 /* FoodSearchView.swift in Sources */,
|
||||||
|
A10016 /* AddFoodSheet.swift in Sources */,
|
||||||
|
A10017 /* HistoryView.swift in Sources */,
|
||||||
|
A10018 /* TemplatesView.swift in Sources */,
|
||||||
|
A10019 /* GoalsView.swift in Sources */,
|
||||||
|
A10020 /* EntryDetailView.swift in Sources */,
|
||||||
|
A10021 /* TodayViewModel.swift in Sources */,
|
||||||
|
A10022 /* FoodSearchViewModel.swift in Sources */,
|
||||||
|
A10023 /* HistoryViewModel.swift in Sources */,
|
||||||
|
A10024 /* TemplatesViewModel.swift in Sources */,
|
||||||
|
A10025 /* GoalsViewModel.swift in Sources */,
|
||||||
|
A10026 /* MacroRing.swift in Sources */,
|
||||||
|
A10027 /* MacroBar.swift in Sources */,
|
||||||
|
A10028 /* LoadingView.swift in Sources */,
|
||||||
|
A10029 /* Date+Extensions.swift in Sources */,
|
||||||
|
A10030 /* Color+Extensions.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
BC10001 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BC10002 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
BC10003 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Platform;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_STRICT_CONCURRENCY = complete;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BC10004 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Platform;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_STRICT_CONCURRENCY = complete;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
CL10001 /* Build configuration list for PBXProject "Platform" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BC10001 /* Debug */,
|
||||||
|
BC10002 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
CL10002 /* Build configuration list for PBXNativeTarget "Platform" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BC10003 /* Debug */,
|
||||||
|
BC10004 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = P10001 /* Project object */;
|
||||||
|
}
|
||||||
7
ios/Platform 1.44.16 AM/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Platform 1.44.16 AM/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>
|
||||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
<?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>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>Platform.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.078",
|
||||||
|
"green" : "0.412",
|
||||||
|
"red" : "0.545"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.157",
|
||||||
|
"green" : "0.502",
|
||||||
|
"red" : "0.667"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
15
ios/Platform 1.44.16 AM/Platform/Config.swift
Normal file
15
ios/Platform 1.44.16 AM/Platform/Config.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Config {
|
||||||
|
static let gatewayURL: URL = {
|
||||||
|
if let override = UserDefaults.standard.string(forKey: "gateway_url"),
|
||||||
|
let url = URL(string: override) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return URL(string: "https://dash.quadjourney.com")!
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func apiURL(_ path: String) -> URL {
|
||||||
|
gatewayURL.appendingPathComponent(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
ios/Platform 1.44.16 AM/Platform/ContentView.swift
Normal file
37
ios/Platform 1.44.16 AM/Platform/ContentView.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@Environment(AuthManager.self) private var authManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if authManager.isCheckingAuth {
|
||||||
|
LoadingView(message: "Checking session...")
|
||||||
|
} else if authManager.isAuthenticated {
|
||||||
|
MainTabView()
|
||||||
|
} else {
|
||||||
|
LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await authManager.checkAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabView: View {
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
HomeView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Home", systemImage: "house.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
FitnessTabView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Fitness", systemImage: "flame.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color.accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
ios/Platform 1.44.16 AM/Platform/Core/APIClient.swift
Normal file
174
ios/Platform 1.44.16 AM/Platform/Core/APIClient.swift
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case httpError(statusCode: Int, body: String?)
|
||||||
|
case decodingError(Error)
|
||||||
|
case networkError(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid URL"
|
||||||
|
case .httpError(let code, let body):
|
||||||
|
return "HTTP \(code): \(body ?? "Unknown error")"
|
||||||
|
case .decodingError(let error):
|
||||||
|
return "Decoding error: \(error.localizedDescription)"
|
||||||
|
case .networkError(let error):
|
||||||
|
return "Network error: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class APIClient: @unchecked Sendable {
|
||||||
|
static let shared = APIClient()
|
||||||
|
|
||||||
|
let session: URLSession
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.httpCookieAcceptPolicy = .always
|
||||||
|
config.httpShouldSetCookies = true
|
||||||
|
config.httpCookieStorage = .shared
|
||||||
|
config.timeoutIntervalForRequest = 30
|
||||||
|
session = URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Decoder
|
||||||
|
|
||||||
|
private var decoder: JSONDecoder {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Request
|
||||||
|
|
||||||
|
func request<T: Decodable>(
|
||||||
|
_ method: String,
|
||||||
|
path: String,
|
||||||
|
body: (any Encodable)? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
let url = Config.apiURL(path)
|
||||||
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||||
|
if let queryItems {
|
||||||
|
components.queryItems = queryItems
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let finalURL = components.url else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: finalURL)
|
||||||
|
request.httpMethod = method
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if let body {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError.networkError(URLError(.badServerResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let body = String(data: data, encoding: .utf8)
|
||||||
|
throw APIError.httpError(statusCode: httpResponse.statusCode, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw APIError.decodingError(error)
|
||||||
|
}
|
||||||
|
} catch let error as APIError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw APIError.networkError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Methods
|
||||||
|
|
||||||
|
func get<T: Decodable>(
|
||||||
|
_ path: String,
|
||||||
|
queryItems: [URLQueryItem]? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
try await request("GET", path: path, queryItems: queryItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func post<T: Decodable>(
|
||||||
|
_ path: String,
|
||||||
|
body: (any Encodable)? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
try await request("POST", path: path, body: body, queryItems: queryItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func patch<T: Decodable>(
|
||||||
|
_ path: String,
|
||||||
|
body: (any Encodable)? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
try await request("PATCH", path: path, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ path: String) async throws {
|
||||||
|
let url = Config.apiURL(path)
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "DELETE"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError.networkError(URLError(.badServerResponse))
|
||||||
|
}
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let body = String(data: data, encoding: .utf8)
|
||||||
|
throw APIError.httpError(statusCode: httpResponse.statusCode, body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST that returns no meaningful body (or we ignore it)
|
||||||
|
func postVoid(
|
||||||
|
_ path: String,
|
||||||
|
body: (any Encodable)? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil
|
||||||
|
) async throws {
|
||||||
|
let url = Config.apiURL(path)
|
||||||
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||||
|
if let queryItems {
|
||||||
|
components.queryItems = queryItems
|
||||||
|
}
|
||||||
|
guard let finalURL = components.url else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: finalURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let body {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
}
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError.networkError(URLError(.badServerResponse))
|
||||||
|
}
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let bodyStr = String(data: data, encoding: .utf8)
|
||||||
|
throw APIError.httpError(statusCode: httpResponse.statusCode, body: bodyStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cookie Management
|
||||||
|
|
||||||
|
func clearCookies() {
|
||||||
|
guard let cookies = HTTPCookieStorage.shared.cookies(for: Config.gatewayURL) else { return }
|
||||||
|
for cookie in cookies {
|
||||||
|
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
ios/Platform 1.44.16 AM/Platform/Core/AuthManager.swift
Normal file
114
ios/Platform 1.44.16 AM/Platform/Core/AuthManager.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class AuthManager {
|
||||||
|
var isAuthenticated = false
|
||||||
|
var isCheckingAuth = true
|
||||||
|
var user: AuthUser?
|
||||||
|
var loginError: String?
|
||||||
|
|
||||||
|
private let api = APIClient.shared
|
||||||
|
|
||||||
|
struct LoginRequest: Encodable {
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponse: Decodable {
|
||||||
|
let success: Bool?
|
||||||
|
let user: AuthUser?
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthResponse: Decodable {
|
||||||
|
let authenticated: Bool?
|
||||||
|
let user: AuthUser?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthUser: Decodable {
|
||||||
|
let id: IntOrString
|
||||||
|
let username: String?
|
||||||
|
let displayName: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, username
|
||||||
|
case displayName = "display_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway returns id as Int, handle both Int and String
|
||||||
|
struct IntOrString: Decodable {
|
||||||
|
let value: String
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let intVal = try? container.decode(Int.self) {
|
||||||
|
value = String(intVal)
|
||||||
|
} else {
|
||||||
|
value = try container.decode(String.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuth() async {
|
||||||
|
isCheckingAuth = true
|
||||||
|
defer { isCheckingAuth = false }
|
||||||
|
|
||||||
|
// Check if we had a previous session
|
||||||
|
guard UserDefaults.standard.bool(forKey: "is_logged_in") else {
|
||||||
|
isAuthenticated = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response: AuthResponse = try await api.get("/api/auth/me")
|
||||||
|
if response.authenticated == true {
|
||||||
|
isAuthenticated = true
|
||||||
|
user = response.user
|
||||||
|
} else {
|
||||||
|
isAuthenticated = false
|
||||||
|
UserDefaults.standard.set(false, forKey: "is_logged_in")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
isAuthenticated = false
|
||||||
|
UserDefaults.standard.set(false, forKey: "is_logged_in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(username: String, password: String) async {
|
||||||
|
loginError = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let body = LoginRequest(username: username, password: password)
|
||||||
|
let loginResp: LoginResponse = try await api.post("/api/auth/login", body: body)
|
||||||
|
|
||||||
|
if loginResp.success == true {
|
||||||
|
user = loginResp.user
|
||||||
|
isAuthenticated = true
|
||||||
|
UserDefaults.standard.set(true, forKey: "is_logged_in")
|
||||||
|
} else {
|
||||||
|
loginError = loginResp.error ?? "Authentication failed"
|
||||||
|
}
|
||||||
|
} catch let error as APIError {
|
||||||
|
switch error {
|
||||||
|
case .httpError(let code, _):
|
||||||
|
if code == 401 {
|
||||||
|
loginError = "Invalid username or password"
|
||||||
|
} else {
|
||||||
|
loginError = "Server error (\(code))"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
loginError = error.localizedDescription
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
loginError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
api.clearCookies()
|
||||||
|
isAuthenticated = false
|
||||||
|
user = nil
|
||||||
|
UserDefaults.standard.set(false, forKey: "is_logged_in")
|
||||||
|
}
|
||||||
|
}
|
||||||
158
ios/Platform 1.44.16 AM/Platform/Features/Auth/LoginView.swift
Normal file
158
ios/Platform 1.44.16 AM/Platform/Features/Auth/LoginView.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@Environment(AuthManager.self) private var authManager
|
||||||
|
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
|
private enum Field: Hashable {
|
||||||
|
case username, password
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.canvas
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 60)
|
||||||
|
|
||||||
|
// Logo / Branding
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "square.grid.2x2.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
|
||||||
|
Text("Platform")
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
|
||||||
|
Text("Sign in to your dashboard")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Username")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
TextField("Enter username", text: $username)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.textContentType(.username)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .username)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(
|
||||||
|
focusedField == .username ? Color.accentWarm : Color.black.opacity(0.06),
|
||||||
|
lineWidth: focusedField == .username ? 2 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Password")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
SecureField("Enter password", text: $password)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(
|
||||||
|
focusedField == .password ? Color.accentWarm : Color.black.opacity(0.06),
|
||||||
|
lineWidth: focusedField == .password ? 2 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = authManager.loginError {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.error)
|
||||||
|
Text(error)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.error)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
|
// Sign In Button
|
||||||
|
Button {
|
||||||
|
performLogin()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
Text("Sign In")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(canSubmit ? Color.accentWarm : Color.text4)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSubmit {
|
||||||
|
switch focusedField {
|
||||||
|
case .username:
|
||||||
|
focusedField = .password
|
||||||
|
case .password:
|
||||||
|
performLogin()
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
!username.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !password.isEmpty
|
||||||
|
&& !isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performLogin() {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
isLoading = true
|
||||||
|
focusedField = nil
|
||||||
|
Task {
|
||||||
|
await authManager.login(
|
||||||
|
username: username.trimmingCharacters(in: .whitespaces),
|
||||||
|
password: password
|
||||||
|
)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FitnessAPI {
|
||||||
|
private let api = APIClient.shared
|
||||||
|
|
||||||
|
// MARK: - Entries
|
||||||
|
|
||||||
|
func getEntries(date: String) async throws -> [FoodEntry] {
|
||||||
|
try await api.get(
|
||||||
|
"/api/fitness/entries",
|
||||||
|
queryItems: [URLQueryItem(name: "date", value: date)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry {
|
||||||
|
try await api.post("/api/fitness/entries", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEntry(id: String, request: UpdateEntryRequest) async throws -> FoodEntry {
|
||||||
|
try await api.patch("/api/fitness/entries/\(id)", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(id: String) async throws {
|
||||||
|
try await api.delete("/api/fitness/entries/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Foods
|
||||||
|
|
||||||
|
func getFoods(limit: Int = 100) async throws -> [FoodItem] {
|
||||||
|
try await api.get(
|
||||||
|
"/api/fitness/foods",
|
||||||
|
queryItems: [URLQueryItem(name: "limit", value: "\(limit)")]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchFoods(query: String, limit: Int = 20) async throws -> [FoodItem] {
|
||||||
|
try await api.get(
|
||||||
|
"/api/fitness/foods/search",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "q", value: query),
|
||||||
|
URLQueryItem(name: "limit", value: "\(limit)")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFood(id: String) async throws -> FoodItem {
|
||||||
|
try await api.get("/api/fitness/foods/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecentFoods(limit: Int = 8) async throws -> [FoodItem] {
|
||||||
|
try await api.get(
|
||||||
|
"/api/fitness/foods/recent",
|
||||||
|
queryItems: [URLQueryItem(name: "limit", value: "\(limit)")]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Goals
|
||||||
|
|
||||||
|
func getGoals(date: String) async throws -> DailyGoal {
|
||||||
|
try await api.get(
|
||||||
|
"/api/fitness/goals/for-date",
|
||||||
|
queryItems: [URLQueryItem(name: "date", value: date)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Templates
|
||||||
|
|
||||||
|
func getTemplates() async throws -> [MealTemplate] {
|
||||||
|
try await api.get("/api/fitness/templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logTemplate(id: String, date: String) async throws {
|
||||||
|
try await api.postVoid(
|
||||||
|
"/api/fitness/templates/\(id)/log",
|
||||||
|
queryItems: [URLQueryItem(name: "date", value: date)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Split
|
||||||
|
|
||||||
|
func splitFood(text: String, mealType: String) async throws -> [FoodItem] {
|
||||||
|
let request = SplitFoodRequest(text: text, mealType: mealType)
|
||||||
|
return try await api.post("/api/fitness/foods/split", body: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FoodEntry: Codable, Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let foodName: String
|
||||||
|
let servingDescription: String?
|
||||||
|
let quantity: Double
|
||||||
|
let unit: String
|
||||||
|
let mealType: String
|
||||||
|
let calories: Double
|
||||||
|
let protein: Double
|
||||||
|
let carbs: Double
|
||||||
|
let fat: Double
|
||||||
|
let sugar: Double?
|
||||||
|
let fiber: Double?
|
||||||
|
let entryType: String?
|
||||||
|
let method: String?
|
||||||
|
let foodId: String?
|
||||||
|
let imageUrl: String?
|
||||||
|
let note: String?
|
||||||
|
let loggedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, quantity, unit, calories, protein, carbs, fat, sugar, fiber, note, method
|
||||||
|
case foodName = "food_name"
|
||||||
|
case servingDescription = "serving_description"
|
||||||
|
case mealType = "meal_type"
|
||||||
|
case entryType = "entry_type"
|
||||||
|
case foodId = "food_id"
|
||||||
|
case imageUrl = "image_url"
|
||||||
|
case loggedAt = "logged_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alternate keys from the snapshot-based API response
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: FlexibleCodingKeys.self)
|
||||||
|
|
||||||
|
id = try container.decode(String.self, forKey: .init("id"))
|
||||||
|
quantity = try container.decodeIfPresent(Double.self, forKey: .init("quantity")) ?? 1
|
||||||
|
unit = try container.decodeIfPresent(String.self, forKey: .init("unit")) ?? "serving"
|
||||||
|
mealType = try container.decodeIfPresent(String.self, forKey: .init("meal_type")) ?? "snack"
|
||||||
|
|
||||||
|
// Handle both "food_name" and "snapshot_food_name"
|
||||||
|
if let name = try container.decodeIfPresent(String.self, forKey: .init("food_name")) {
|
||||||
|
foodName = name
|
||||||
|
} else if let name = try container.decodeIfPresent(String.self, forKey: .init("snapshot_food_name")) {
|
||||||
|
foodName = name
|
||||||
|
} else {
|
||||||
|
foodName = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
servingDescription = try container.decodeIfPresent(String.self, forKey: .init("serving_description"))
|
||||||
|
?? container.decodeIfPresent(String.self, forKey: .init("snapshot_serving_label"))
|
||||||
|
|
||||||
|
// Handle both direct and snapshot_ prefixed fields
|
||||||
|
calories = try container.decodeIfPresent(Double.self, forKey: .init("calories"))
|
||||||
|
?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_calories"))
|
||||||
|
?? 0
|
||||||
|
protein = try container.decodeIfPresent(Double.self, forKey: .init("protein"))
|
||||||
|
?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_protein"))
|
||||||
|
?? 0
|
||||||
|
carbs = try container.decodeIfPresent(Double.self, forKey: .init("carbs"))
|
||||||
|
?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_carbs"))
|
||||||
|
?? 0
|
||||||
|
fat = try container.decodeIfPresent(Double.self, forKey: .init("fat"))
|
||||||
|
?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_fat"))
|
||||||
|
?? 0
|
||||||
|
sugar = try container.decodeIfPresent(Double.self, forKey: .init("sugar"))
|
||||||
|
?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_sugar"))
|
||||||
|
fiber = try container.decodeIfPresent(Double.self, forKey: .init("fiber"))
|
||||||
|
?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_fiber"))
|
||||||
|
|
||||||
|
entryType = try container.decodeIfPresent(String.self, forKey: .init("entry_type"))
|
||||||
|
method = try container.decodeIfPresent(String.self, forKey: .init("entry_method"))
|
||||||
|
?? container.decodeIfPresent(String.self, forKey: .init("method"))
|
||||||
|
foodId = try container.decodeIfPresent(String.self, forKey: .init("food_id"))
|
||||||
|
imageUrl = try container.decodeIfPresent(String.self, forKey: .init("image_url"))
|
||||||
|
?? container.decodeIfPresent(String.self, forKey: .init("food_image_path"))
|
||||||
|
note = try container.decodeIfPresent(String.self, forKey: .init("note"))
|
||||||
|
loggedAt = try container.decodeIfPresent(String.self, forKey: .init("logged_at"))
|
||||||
|
?? container.decodeIfPresent(String.self, forKey: .init("created_at"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(foodName, forKey: .foodName)
|
||||||
|
try container.encodeIfPresent(servingDescription, forKey: .servingDescription)
|
||||||
|
try container.encode(quantity, forKey: .quantity)
|
||||||
|
try container.encode(unit, forKey: .unit)
|
||||||
|
try container.encode(mealType, forKey: .mealType)
|
||||||
|
try container.encode(calories, forKey: .calories)
|
||||||
|
try container.encode(protein, forKey: .protein)
|
||||||
|
try container.encode(carbs, forKey: .carbs)
|
||||||
|
try container.encode(fat, forKey: .fat)
|
||||||
|
try container.encodeIfPresent(sugar, forKey: .sugar)
|
||||||
|
try container.encodeIfPresent(fiber, forKey: .fiber)
|
||||||
|
try container.encodeIfPresent(entryType, forKey: .entryType)
|
||||||
|
try container.encodeIfPresent(method, forKey: .method)
|
||||||
|
try container.encodeIfPresent(foodId, forKey: .foodId)
|
||||||
|
try container.encodeIfPresent(imageUrl, forKey: .imageUrl)
|
||||||
|
try container.encodeIfPresent(note, forKey: .note)
|
||||||
|
try container.encodeIfPresent(loggedAt, forKey: .loggedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: FoodEntry, rhs: FoodEntry) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flexible coding keys for handling multiple API shapes
|
||||||
|
struct FlexibleCodingKeys: CodingKey {
|
||||||
|
var stringValue: String
|
||||||
|
var intValue: Int?
|
||||||
|
|
||||||
|
init(_ string: String) {
|
||||||
|
self.stringValue = string
|
||||||
|
self.intValue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(stringValue: String) {
|
||||||
|
self.stringValue = stringValue
|
||||||
|
self.intValue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(intValue: Int) {
|
||||||
|
self.stringValue = "\(intValue)"
|
||||||
|
self.intValue = intValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FoodItem: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let brand: String?
|
||||||
|
let baseUnit: String?
|
||||||
|
let caloriesPerBase: Double
|
||||||
|
let proteinPerBase: Double?
|
||||||
|
let carbsPerBase: Double?
|
||||||
|
let fatPerBase: Double?
|
||||||
|
let sugarPerBase: Double?
|
||||||
|
let fiberPerBase: Double?
|
||||||
|
let status: String?
|
||||||
|
let imageUrl: String?
|
||||||
|
let favorite: Bool?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, brand, status, favorite
|
||||||
|
case baseUnit = "base_unit"
|
||||||
|
case caloriesPerBase = "calories_per_base"
|
||||||
|
case proteinPerBase = "protein_per_base"
|
||||||
|
case carbsPerBase = "carbs_per_base"
|
||||||
|
case fatPerBase = "fat_per_base"
|
||||||
|
case sugarPerBase = "sugar_per_base"
|
||||||
|
case fiberPerBase = "fiber_per_base"
|
||||||
|
case imageUrl = "image_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayUnit: String {
|
||||||
|
baseUnit ?? "serving"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayInfo: String {
|
||||||
|
let parts = [brand].compactMap { $0 }
|
||||||
|
let prefix = parts.isEmpty ? "" : "\(parts.joined(separator: " ")) - "
|
||||||
|
return "\(prefix)\(displayUnit)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaledCalories(quantity: Double) -> Double {
|
||||||
|
caloriesPerBase * quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaledProtein(quantity: Double) -> Double {
|
||||||
|
(proteinPerBase ?? 0) * quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaledCarbs(quantity: Double) -> Double {
|
||||||
|
(carbsPerBase ?? 0) * quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaledFat(quantity: Double) -> Double {
|
||||||
|
(fatPerBase ?? 0) * quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailyGoal: Codable {
|
||||||
|
let calories: Double
|
||||||
|
let protein: Double
|
||||||
|
let carbs: Double
|
||||||
|
let fat: Double
|
||||||
|
let sugar: Double?
|
||||||
|
let fiber: Double?
|
||||||
|
|
||||||
|
static let defaultGoal = DailyGoal(
|
||||||
|
calories: 2000, protein: 150, carbs: 200, fat: 65, sugar: 50, fiber: 30
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MealTemplate: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let mealType: String
|
||||||
|
let calories: Double
|
||||||
|
let protein: Double?
|
||||||
|
let carbs: Double?
|
||||||
|
let fat: Double?
|
||||||
|
let itemsCount: Int?
|
||||||
|
|
||||||
|
// Support flexible decoding for templates with nested items
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, calories, protein, carbs, fat, items
|
||||||
|
case mealType = "meal_type"
|
||||||
|
case itemsCount = "items_count"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(String.self, forKey: .id)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
mealType = try container.decodeIfPresent(String.self, forKey: .mealType) ?? "snack"
|
||||||
|
protein = try container.decodeIfPresent(Double.self, forKey: .protein)
|
||||||
|
carbs = try container.decodeIfPresent(Double.self, forKey: .carbs)
|
||||||
|
fat = try container.decodeIfPresent(Double.self, forKey: .fat)
|
||||||
|
|
||||||
|
// Try direct calories first, then compute from items
|
||||||
|
if let directCals = try? container.decode(Double.self, forKey: .calories) {
|
||||||
|
calories = directCals
|
||||||
|
itemsCount = try container.decodeIfPresent(Int.self, forKey: .itemsCount)
|
||||||
|
} else if let items = try? container.decode([TemplateItem].self, forKey: .items) {
|
||||||
|
calories = items.reduce(0) { $0 + ($1.snapshotCalories ?? 0) * ($1.quantity ?? 1) }
|
||||||
|
itemsCount = items.count
|
||||||
|
} else {
|
||||||
|
calories = 0
|
||||||
|
itemsCount = try container.decodeIfPresent(Int.self, forKey: .itemsCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(mealType, forKey: .mealType)
|
||||||
|
try container.encode(calories, forKey: .calories)
|
||||||
|
try container.encodeIfPresent(protein, forKey: .protein)
|
||||||
|
try container.encodeIfPresent(carbs, forKey: .carbs)
|
||||||
|
try container.encodeIfPresent(fat, forKey: .fat)
|
||||||
|
try container.encodeIfPresent(itemsCount, forKey: .itemsCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TemplateItem: Codable {
|
||||||
|
let snapshotCalories: Double?
|
||||||
|
let quantity: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case snapshotCalories = "snapshot_calories"
|
||||||
|
case quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateEntryRequest: Encodable {
|
||||||
|
let foodId: String
|
||||||
|
let quantity: Double
|
||||||
|
let unit: String
|
||||||
|
let mealType: String
|
||||||
|
let entryDate: String
|
||||||
|
let entryMethod: String
|
||||||
|
let source: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case quantity, unit, source
|
||||||
|
case foodId = "food_id"
|
||||||
|
case mealType = "meal_type"
|
||||||
|
case entryDate = "entry_date"
|
||||||
|
case entryMethod = "entry_method"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateEntryRequest: Encodable {
|
||||||
|
var quantity: Double?
|
||||||
|
var unit: String?
|
||||||
|
var mealType: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case quantity, unit
|
||||||
|
case mealType = "meal_type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SplitFoodRequest: Encodable {
|
||||||
|
let text: String
|
||||||
|
let mealType: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case text
|
||||||
|
case mealType = "meal_type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meals enum for type safety
|
||||||
|
enum MealType: String, CaseIterable, Identifiable {
|
||||||
|
case breakfast
|
||||||
|
case lunch
|
||||||
|
case dinner
|
||||||
|
case snack
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
rawValue.capitalized
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .breakfast: return "sunrise.fill"
|
||||||
|
case .lunch: return "sun.max.fill"
|
||||||
|
case .dinner: return "moon.fill"
|
||||||
|
case .snack: return "leaf.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func guess() -> MealType {
|
||||||
|
let hour = Calendar.current.component(.hour, from: Date())
|
||||||
|
switch hour {
|
||||||
|
case 5..<11: return .breakfast
|
||||||
|
case 11..<15: return .lunch
|
||||||
|
case 15..<20: return .dinner
|
||||||
|
default: return .snack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for grouping entries by meal
|
||||||
|
struct MealGroup: Identifiable {
|
||||||
|
let meal: MealType
|
||||||
|
let entries: [FoodEntry]
|
||||||
|
|
||||||
|
var id: String { meal.rawValue }
|
||||||
|
|
||||||
|
var totalCalories: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.calories }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProtein: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.protein }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCarbs: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.carbs }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFat: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.fat }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class FitnessRepository {
|
||||||
|
static let shared = FitnessRepository()
|
||||||
|
|
||||||
|
private let api = FitnessAPI()
|
||||||
|
|
||||||
|
// Cached data
|
||||||
|
var cachedFoods: [FoodItem] = []
|
||||||
|
var cachedRecentFoods: [FoodItem] = []
|
||||||
|
var cachedTemplates: [MealTemplate] = []
|
||||||
|
var cachedGoals: [String: DailyGoal] = [:]
|
||||||
|
var cachedEntries: [String: [FoodEntry]] = [:]
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Entries
|
||||||
|
|
||||||
|
func entries(for date: String, forceRefresh: Bool = false) async throws -> [FoodEntry] {
|
||||||
|
if !forceRefresh, let cached = cachedEntries[date] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
let entries = try await api.getEntries(date: date)
|
||||||
|
cachedEntries[date] = entries
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry {
|
||||||
|
let entry = try await api.createEntry(request)
|
||||||
|
// Invalidate cache for that date
|
||||||
|
cachedEntries[request.entryDate] = nil
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEntry(id: String, request: UpdateEntryRequest, date: String) async throws -> FoodEntry {
|
||||||
|
let entry = try await api.updateEntry(id: id, request: request)
|
||||||
|
cachedEntries[date] = nil
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(id: String, date: String) async throws {
|
||||||
|
try await api.deleteEntry(id: id)
|
||||||
|
cachedEntries[date] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Foods
|
||||||
|
|
||||||
|
func allFoods(forceRefresh: Bool = false) async throws -> [FoodItem] {
|
||||||
|
if !forceRefresh, !cachedFoods.isEmpty {
|
||||||
|
return cachedFoods
|
||||||
|
}
|
||||||
|
cachedFoods = try await api.getFoods()
|
||||||
|
return cachedFoods
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchFoods(query: String) async throws -> [FoodItem] {
|
||||||
|
try await api.searchFoods(query: query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentFoods(forceRefresh: Bool = false) async throws -> [FoodItem] {
|
||||||
|
if !forceRefresh, !cachedRecentFoods.isEmpty {
|
||||||
|
return cachedRecentFoods
|
||||||
|
}
|
||||||
|
cachedRecentFoods = try await api.getRecentFoods()
|
||||||
|
return cachedRecentFoods
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Goals
|
||||||
|
|
||||||
|
func goals(for date: String, forceRefresh: Bool = false) async throws -> DailyGoal {
|
||||||
|
if !forceRefresh, let cached = cachedGoals[date] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
let goal = try await api.getGoals(date: date)
|
||||||
|
cachedGoals[date] = goal
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Templates
|
||||||
|
|
||||||
|
func templates(forceRefresh: Bool = false) async throws -> [MealTemplate] {
|
||||||
|
if !forceRefresh, !cachedTemplates.isEmpty {
|
||||||
|
return cachedTemplates
|
||||||
|
}
|
||||||
|
cachedTemplates = try await api.getTemplates()
|
||||||
|
return cachedTemplates
|
||||||
|
}
|
||||||
|
|
||||||
|
func logTemplate(id: String, date: String) async throws {
|
||||||
|
try await api.logTemplate(id: id, date: date)
|
||||||
|
cachedEntries[date] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Invalidation
|
||||||
|
|
||||||
|
func invalidateAll() {
|
||||||
|
cachedFoods = []
|
||||||
|
cachedRecentFoods = []
|
||||||
|
cachedTemplates = []
|
||||||
|
cachedGoals = [:]
|
||||||
|
cachedEntries = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class FoodSearchViewModel {
|
||||||
|
var searchText = ""
|
||||||
|
var searchResults: [FoodItem] = []
|
||||||
|
var recentFoods: [FoodItem] = []
|
||||||
|
var isSearching = false
|
||||||
|
var isLoadingRecent = false
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
// Add food sheet state
|
||||||
|
var selectedFood: FoodItem?
|
||||||
|
var showAddSheet = false
|
||||||
|
var addQuantity: Double = 1
|
||||||
|
var addMealType: MealType = .guess()
|
||||||
|
var isAddingFood = false
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var displayedFoods: [FoodItem] {
|
||||||
|
if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
return recentFoods
|
||||||
|
}
|
||||||
|
return searchResults
|
||||||
|
}
|
||||||
|
|
||||||
|
var isShowingRecent: Bool {
|
||||||
|
searchText.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecent() async {
|
||||||
|
isLoadingRecent = true
|
||||||
|
do {
|
||||||
|
recentFoods = try await repo.recentFoods(forceRefresh: true)
|
||||||
|
} catch {
|
||||||
|
// Silent failure for recent foods
|
||||||
|
}
|
||||||
|
isLoadingRecent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func search() {
|
||||||
|
let query = searchText.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Cancel previous search
|
||||||
|
searchTask?.cancel()
|
||||||
|
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
searchResults = []
|
||||||
|
isSearching = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard query.count >= 2 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true
|
||||||
|
searchTask = Task {
|
||||||
|
// Debounce
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let results = try await repo.searchFoods(query: query)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
searchResults = results
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectFood(_ food: FoodItem) {
|
||||||
|
selectedFood = food
|
||||||
|
addQuantity = 1
|
||||||
|
addMealType = .guess()
|
||||||
|
showAddSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFood(date: String, onComplete: @escaping () -> Void) async {
|
||||||
|
guard let food = selectedFood else { return }
|
||||||
|
isAddingFood = true
|
||||||
|
|
||||||
|
let request = CreateEntryRequest(
|
||||||
|
foodId: food.id,
|
||||||
|
quantity: addQuantity,
|
||||||
|
unit: food.baseUnit ?? "serving",
|
||||||
|
mealType: addMealType.rawValue,
|
||||||
|
entryDate: date,
|
||||||
|
entryMethod: "manual",
|
||||||
|
source: "ios_app"
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await repo.createEntry(request)
|
||||||
|
showAddSheet = false
|
||||||
|
selectedFood = nil
|
||||||
|
onComplete()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to add food: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
isAddingFood = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class GoalsViewModel {
|
||||||
|
var goal: DailyGoal = .defaultGoal
|
||||||
|
var isLoading = true
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
goal = try await repo.goals(for: Date().apiDateString, forceRefresh: true)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class HistoryViewModel {
|
||||||
|
var days: [HistoryDay] = []
|
||||||
|
var isLoading = true
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
private let numberOfDays = 14
|
||||||
|
|
||||||
|
struct HistoryDay: Identifiable {
|
||||||
|
let date: Date
|
||||||
|
let dateString: String
|
||||||
|
let entries: [FoodEntry]
|
||||||
|
let goal: DailyGoal
|
||||||
|
|
||||||
|
var id: String { dateString }
|
||||||
|
|
||||||
|
var totalCalories: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.calories }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProtein: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.protein }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCarbs: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.carbs }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFat: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.fat }
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryCount: Int {
|
||||||
|
entries.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var calorieProgress: Double {
|
||||||
|
guard goal.calories > 0 else { return 0 }
|
||||||
|
return min(totalCalories / goal.calories, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
var results: [HistoryDay] = []
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Load past N days
|
||||||
|
for i in 0..<numberOfDays {
|
||||||
|
let date = Date().adding(days: -i)
|
||||||
|
let dateString = date.apiDateString
|
||||||
|
|
||||||
|
let entries = try await repo.entries(for: dateString, forceRefresh: i == 0)
|
||||||
|
let goal = try await repo.goals(for: dateString)
|
||||||
|
|
||||||
|
results.append(HistoryDay(
|
||||||
|
date: date,
|
||||||
|
dateString: dateString,
|
||||||
|
entries: entries,
|
||||||
|
goal: goal
|
||||||
|
))
|
||||||
|
}
|
||||||
|
days = results
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
days = results // Show what we have
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class TemplatesViewModel {
|
||||||
|
var templates: [MealTemplate] = []
|
||||||
|
var isLoading = true
|
||||||
|
var errorMessage: String?
|
||||||
|
var isLogging = false
|
||||||
|
var loggedTemplateId: String?
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
templates = try await repo.templates(forceRefresh: true)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func logTemplate(_ template: MealTemplate, date: String, onComplete: @escaping () -> Void) async {
|
||||||
|
isLogging = true
|
||||||
|
loggedTemplateId = template.id
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await repo.logTemplate(id: template.id, date: date)
|
||||||
|
loggedTemplateId = nil
|
||||||
|
onComplete()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to log template: \(error.localizedDescription)"
|
||||||
|
loggedTemplateId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isLogging = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupedTemplates: [String: [MealTemplate]] {
|
||||||
|
Dictionary(grouping: templates, by: \.mealType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class TodayViewModel {
|
||||||
|
var entries: [FoodEntry] = []
|
||||||
|
var goal: DailyGoal = .defaultGoal
|
||||||
|
var selectedDate: Date = Date()
|
||||||
|
var isLoading = true
|
||||||
|
var errorMessage: String?
|
||||||
|
var expandedMeals: Set<String> = Set(MealType.allCases.map(\.rawValue))
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var dateString: String {
|
||||||
|
selectedDate.apiDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
var mealGroups: [MealGroup] {
|
||||||
|
MealType.allCases.map { meal in
|
||||||
|
MealGroup(
|
||||||
|
meal: meal,
|
||||||
|
entries: entries.filter { $0.mealType == meal.rawValue }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCalories: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.calories }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProtein: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.protein }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCarbs: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.carbs }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFat: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.fat }
|
||||||
|
}
|
||||||
|
|
||||||
|
var caloriesRemaining: Double {
|
||||||
|
max(goal.calories - totalCalories, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
async let entriesTask = repo.entries(for: dateString, forceRefresh: true)
|
||||||
|
async let goalsTask = repo.goals(for: dateString)
|
||||||
|
|
||||||
|
entries = try await entriesTask
|
||||||
|
goal = try await goalsTask
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToNextDay() {
|
||||||
|
selectedDate = selectedDate.adding(days: 1)
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToPreviousDay() {
|
||||||
|
selectedDate = selectedDate.adding(days: -1)
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToToday() {
|
||||||
|
selectedDate = Date()
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMeal(_ meal: String) {
|
||||||
|
if expandedMeals.contains(meal) {
|
||||||
|
expandedMeals.remove(meal)
|
||||||
|
} else {
|
||||||
|
expandedMeals.insert(meal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(_ entry: FoodEntry) async {
|
||||||
|
// Optimistic removal
|
||||||
|
entries.removeAll { $0.id == entry.id }
|
||||||
|
do {
|
||||||
|
try await repo.deleteEntry(id: entry.id, date: dateString)
|
||||||
|
} catch {
|
||||||
|
// Reload on failure
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEntryQuantity(id: String, quantity: Double) async {
|
||||||
|
let request = UpdateEntryRequest(quantity: quantity)
|
||||||
|
do {
|
||||||
|
_ = try await repo.updateEntry(id: id, request: request, date: dateString)
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to update entry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddFoodSheet: View {
|
||||||
|
let food: FoodItem
|
||||||
|
@Binding var quantity: Double
|
||||||
|
@Binding var mealType: MealType
|
||||||
|
let isAdding: Bool
|
||||||
|
let onAdd: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var quantityText: String = "1"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Food info header
|
||||||
|
foodHeader
|
||||||
|
|
||||||
|
// Quantity input
|
||||||
|
quantitySection
|
||||||
|
|
||||||
|
// Meal picker
|
||||||
|
mealPickerSection
|
||||||
|
|
||||||
|
// Macro preview
|
||||||
|
macroPreview
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Add button
|
||||||
|
Button(action: onAdd) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isAdding {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
Text("Add to \(mealType.displayName)")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.disabled(isAdding || quantity <= 0)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationTitle("Add Food")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
quantityText = formatQuantity(quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foodHeader: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.accentWarmBg)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(food.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text("\(Int(food.caloriesPerBase)) kcal per \(food.displayUnit)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quantitySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Quantity (\(food.displayUnit))")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Decrement
|
||||||
|
Button {
|
||||||
|
adjustQuantity(by: -0.5)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(quantity > 0.5 ? Color.accentWarm : Color.text4)
|
||||||
|
}
|
||||||
|
.disabled(quantity <= 0.5)
|
||||||
|
|
||||||
|
// Text field
|
||||||
|
TextField("1", text: $quantityText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(width: 80)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.onChange(of: quantityText) {
|
||||||
|
if let val = Double(quantityText), val > 0 {
|
||||||
|
quantity = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment
|
||||||
|
Button {
|
||||||
|
adjustQuantity(by: 0.5)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Quick presets
|
||||||
|
ForEach([0.5, 1.0, 2.0], id: \.self) { preset in
|
||||||
|
Button {
|
||||||
|
quantity = preset
|
||||||
|
quantityText = formatQuantity(preset)
|
||||||
|
} label: {
|
||||||
|
Text(formatQuantity(preset))
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(quantity == preset ? .white : Color.text2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(quantity == preset ? Color.accentWarm : Color.surfaceSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mealPickerSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Meal")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(MealType.allCases) { meal in
|
||||||
|
Button {
|
||||||
|
mealType = meal
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: meal.icon)
|
||||||
|
.font(.body)
|
||||||
|
Text(meal.displayName)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(mealType == meal ? .white : Color.text2)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
mealType == meal
|
||||||
|
? Color.mealColor(for: meal.rawValue)
|
||||||
|
: Color.surfaceSecondary
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var macroPreview: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Nutrition Preview")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
macroPreviewItem("Calories", value: food.scaledCalories(quantity: quantity), unit: "kcal", color: .caloriesColor)
|
||||||
|
Spacer()
|
||||||
|
macroPreviewItem("Protein", value: food.scaledProtein(quantity: quantity), unit: "g", color: .proteinColor)
|
||||||
|
Spacer()
|
||||||
|
macroPreviewItem("Carbs", value: food.scaledCarbs(quantity: quantity), unit: "g", color: .carbsColor)
|
||||||
|
Spacer()
|
||||||
|
macroPreviewItem("Fat", value: food.scaledFat(quantity: quantity), unit: "g", color: .fatColor)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroPreviewItem(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(.system(.title3, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adjustQuantity(by amount: Double) {
|
||||||
|
quantity = max(0.5, quantity + amount)
|
||||||
|
quantityText = formatQuantity(quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatQuantity(_ qty: Double) -> String {
|
||||||
|
if qty == qty.rounded() {
|
||||||
|
return "\(Int(qty))"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", qty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EntryDetailView: View {
|
||||||
|
let entry: FoodEntry
|
||||||
|
let onDelete: () -> Void
|
||||||
|
let onUpdateQuantity: (Double) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var editQuantity: String
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
|
init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) {
|
||||||
|
self.entry = entry
|
||||||
|
self.onDelete = onDelete
|
||||||
|
self.onUpdateQuantity = onUpdateQuantity
|
||||||
|
_editQuantity = State(initialValue: entry.quantity == entry.quantity.rounded() ? "\(Int(entry.quantity))" : String(format: "%.1f", entry.quantity))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
entryHeader
|
||||||
|
|
||||||
|
// Quantity editor
|
||||||
|
quantityEditor
|
||||||
|
|
||||||
|
// Macros grid
|
||||||
|
macrosGrid
|
||||||
|
|
||||||
|
// Details
|
||||||
|
detailsSection
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showDeleteConfirm = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
Text("Delete Entry")
|
||||||
|
}
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.foregroundStyle(Color.error)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Color.error.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationTitle("Entry Details")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("Delete Entry", isPresented: $showDeleteConfirm) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
onDelete()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to delete \"\(entry.foodName)\"?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entryHeader: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
|
||||||
|
Image(systemName: Color.mealIcon(for: entry.mealType))
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(entry.foodName)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(entry.mealType.capitalized)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.mealColor(for: entry.mealType).opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quantityEditor: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Quantity")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
let current = Double(editQuantity) ?? 1
|
||||||
|
let newVal = max(0.5, current - 0.5)
|
||||||
|
editQuantity = formatQuantity(newVal)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("1", text: $editQuantity)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(width: 80)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
let current = Double(editQuantity) ?? 1
|
||||||
|
editQuantity = formatQuantity(current + 0.5)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Save") {
|
||||||
|
if let qty = Double(editQuantity), qty > 0 {
|
||||||
|
onUpdateQuantity(qty)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var macrosGrid: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Nutrition")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 12) {
|
||||||
|
macroCell("Calories", value: entry.calories, unit: "kcal", color: .caloriesColor)
|
||||||
|
macroCell("Protein", value: entry.protein, unit: "g", color: .proteinColor)
|
||||||
|
macroCell("Carbs", value: entry.carbs, unit: "g", color: .carbsColor)
|
||||||
|
macroCell("Fat", value: entry.fat, unit: "g", color: .fatColor)
|
||||||
|
if let sugar = entry.sugar {
|
||||||
|
macroCell("Sugar", value: sugar, unit: "g", color: .sugarColor)
|
||||||
|
}
|
||||||
|
if let fiber = entry.fiber {
|
||||||
|
macroCell("Fiber", value: fiber, unit: "g", color: .fiberColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(.title3.weight(.bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(color.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detailsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Details")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
detailRow("Serving", value: entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
|
||||||
|
|
||||||
|
if let method = entry.method, !method.isEmpty {
|
||||||
|
Divider()
|
||||||
|
detailRow("Method", value: method)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let note = entry.note, !note.isEmpty {
|
||||||
|
Divider()
|
||||||
|
detailRow("Note", value: note)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let loggedAt = entry.loggedAt, !loggedAt.isEmpty {
|
||||||
|
Divider()
|
||||||
|
detailRow("Logged", value: loggedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailRow(_ label: String, value: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatQuantity(_ qty: Double) -> String {
|
||||||
|
if qty == qty.rounded() {
|
||||||
|
return "\(Int(qty))"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", qty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FitnessTabView: View {
|
||||||
|
@State private var selectedTab: FitnessTab = .today
|
||||||
|
@State private var todayVM = TodayViewModel()
|
||||||
|
@State private var showFoodSearch = false
|
||||||
|
|
||||||
|
enum FitnessTab: String, CaseIterable {
|
||||||
|
case today = "Today"
|
||||||
|
case history = "History"
|
||||||
|
case templates = "Templates"
|
||||||
|
case goals = "Goals"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Custom segmented control
|
||||||
|
tabBar
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Group {
|
||||||
|
switch selectedTab {
|
||||||
|
case .today:
|
||||||
|
TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch)
|
||||||
|
case .history:
|
||||||
|
HistoryView()
|
||||||
|
case .templates:
|
||||||
|
TemplatesView(dateString: todayVM.dateString) {
|
||||||
|
Task { await todayVM.load() }
|
||||||
|
}
|
||||||
|
case .goals:
|
||||||
|
GoalsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationTitle("Fitness")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.sheet(isPresented: $showFoodSearch) {
|
||||||
|
FoodSearchView(date: todayVM.dateString) {
|
||||||
|
Task { await todayVM.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tabBar: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(FitnessTab.allCases, id: \.rawValue) { tab in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
selectedTab = tab
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(tab.rawValue)
|
||||||
|
.font(.subheadline.weight(selectedTab == tab ? .semibold : .medium))
|
||||||
|
.foregroundStyle(selectedTab == tab ? Color.surface : Color.text3)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
selectedTab == tab
|
||||||
|
? Color.accentWarm
|
||||||
|
: Color.surfaceSecondary
|
||||||
|
)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FoodSearchView: View {
|
||||||
|
let date: String
|
||||||
|
let onFoodAdded: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var viewModel = FoodSearchViewModel()
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Search bar
|
||||||
|
searchBar
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if viewModel.isSearching || viewModel.isLoadingRecent {
|
||||||
|
LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...")
|
||||||
|
} else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "magnifyingglass",
|
||||||
|
title: "No results",
|
||||||
|
subtitle: "Try a different search term"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
foodList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationTitle("Add Food")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showAddSheet) {
|
||||||
|
if let food = viewModel.selectedFood {
|
||||||
|
AddFoodSheet(
|
||||||
|
food: food,
|
||||||
|
quantity: $viewModel.addQuantity,
|
||||||
|
mealType: $viewModel.addMealType,
|
||||||
|
isAdding: viewModel.isAddingFood
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
await viewModel.addFood(date: date) {
|
||||||
|
onFoodAdded()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadRecent()
|
||||||
|
searchFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchBar: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
|
||||||
|
TextField("Search foods...", text: $viewModel.searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.focused($searchFocused)
|
||||||
|
.onSubmit {
|
||||||
|
viewModel.search()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.searchText) {
|
||||||
|
viewModel.search()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
viewModel.searchText = ""
|
||||||
|
viewModel.searchResults = []
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foodList: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty {
|
||||||
|
sectionHeader("Recent Foods")
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.displayedFoods) { food in
|
||||||
|
FoodItemRow(food: food) {
|
||||||
|
viewModel.selectFood(food)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FoodItemRow: View {
|
||||||
|
let food: FoodItem
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Icon
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.accentWarmBg)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
if let imageUrl = food.imageUrl, !imageUrl.isEmpty {
|
||||||
|
AsyncImage(url: URL(string: imageUrl)) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
} placeholder: {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(food.name)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(food.displayInfo)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Calories
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(Int(food.caloriesPerBase))")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
|
||||||
|
Text("kcal")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GoalsView: View {
|
||||||
|
@State private var viewModel = GoalsViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
LoadingView(message: "Loading goals...")
|
||||||
|
.frame(height: 300)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
Text("Your daily targets")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Goals cards
|
||||||
|
goalCard(
|
||||||
|
label: "Calories",
|
||||||
|
value: viewModel.goal.calories,
|
||||||
|
unit: "kcal",
|
||||||
|
icon: "flame.fill",
|
||||||
|
color: .caloriesColor
|
||||||
|
)
|
||||||
|
|
||||||
|
goalCard(
|
||||||
|
label: "Protein",
|
||||||
|
value: viewModel.goal.protein,
|
||||||
|
unit: "g",
|
||||||
|
icon: "circle.hexagonpath.fill",
|
||||||
|
color: .proteinColor
|
||||||
|
)
|
||||||
|
|
||||||
|
goalCard(
|
||||||
|
label: "Carbs",
|
||||||
|
value: viewModel.goal.carbs,
|
||||||
|
unit: "g",
|
||||||
|
icon: "bolt.fill",
|
||||||
|
color: .carbsColor
|
||||||
|
)
|
||||||
|
|
||||||
|
goalCard(
|
||||||
|
label: "Fat",
|
||||||
|
value: viewModel.goal.fat,
|
||||||
|
unit: "g",
|
||||||
|
icon: "drop.fill",
|
||||||
|
color: .fatColor
|
||||||
|
)
|
||||||
|
|
||||||
|
if let sugar = viewModel.goal.sugar, sugar > 0 {
|
||||||
|
goalCard(
|
||||||
|
label: "Sugar",
|
||||||
|
value: sugar,
|
||||||
|
unit: "g",
|
||||||
|
icon: "cube.fill",
|
||||||
|
color: .sugarColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fiber = viewModel.goal.fiber, fiber > 0 {
|
||||||
|
goalCard(
|
||||||
|
label: "Fiber",
|
||||||
|
value: fiber,
|
||||||
|
unit: "g",
|
||||||
|
icon: "leaf.fill",
|
||||||
|
color: .fiberColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info note
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
Text("Goals can be updated from the web app")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goalCard(label: String, value: Double, unit: String, icon: String, color: Color) -> some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(color.opacity(0.1))
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
|
||||||
|
Text("Daily target")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text(unit)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HistoryView: View {
|
||||||
|
@State private var viewModel = HistoryViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
LoadingView(message: "Loading history...")
|
||||||
|
.frame(height: 300)
|
||||||
|
} else if viewModel.days.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "calendar",
|
||||||
|
title: "No history",
|
||||||
|
subtitle: "Start logging food to see your history"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
ForEach(viewModel.days) { day in
|
||||||
|
HistoryDayCard(day: day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistoryDayCard: View {
|
||||||
|
let day: HistoryViewModel.HistoryDay
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Date
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(day.date.relativeLabel)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text(day.date.shortDisplayString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Quick stats
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(Int(day.totalCalories))")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text("kcal")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini progress ring
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.caloriesColor.opacity(0.12), lineWidth: 3)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: day.calorieProgress)
|
||||||
|
.stroke(Color.caloriesColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
}
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Macros row
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
historyMacro("Protein", value: day.totalProtein, color: .proteinColor)
|
||||||
|
Spacer()
|
||||||
|
historyMacro("Carbs", value: day.totalCarbs, color: .carbsColor)
|
||||||
|
Spacer()
|
||||||
|
historyMacro("Fat", value: day.totalFat, color: .fatColor)
|
||||||
|
Spacer()
|
||||||
|
historyMacro("Entries", value: Double(day.entryCount), color: .text3, isCount: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
if !day.entries.isEmpty {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Entries list
|
||||||
|
ForEach(day.entries) { entry in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.mealColor(for: entry.mealType))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
|
||||||
|
Text(entry.foodName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(Int(entry.calories)) kcal")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func historyMacro(_ label: String, value: Double, color: Color, isCount: Bool = false) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(Int(value))\(isCount ? "" : "g")")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MealSectionView: View {
|
||||||
|
let group: MealGroup
|
||||||
|
let isExpanded: Bool
|
||||||
|
let onToggle: () -> Void
|
||||||
|
let onDelete: (FoodEntry) -> Void
|
||||||
|
let onAddFood: () -> Void
|
||||||
|
|
||||||
|
@State private var selectedEntry: FoodEntry?
|
||||||
|
|
||||||
|
private var mealColor: Color {
|
||||||
|
Color.mealColor(for: group.meal.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
Button(action: onToggle) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: group.meal.icon)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(mealColor)
|
||||||
|
.frame(width: 28)
|
||||||
|
|
||||||
|
Text(group.meal.displayName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
|
||||||
|
if !group.entries.isEmpty {
|
||||||
|
Text("\(group.entries.count)")
|
||||||
|
.font(.caption2.weight(.bold))
|
||||||
|
.foregroundStyle(mealColor)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(mealColor.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !group.entries.isEmpty {
|
||||||
|
Text("\(Int(group.totalCalories)) kcal")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Entries
|
||||||
|
if isExpanded {
|
||||||
|
if group.entries.isEmpty {
|
||||||
|
emptyMealView
|
||||||
|
} else {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
ForEach(group.entries) { entry in
|
||||||
|
SwipeToDeleteRow(onDelete: { onDelete(entry) }) {
|
||||||
|
EntryRow(entry: entry)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedEntry = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.id != group.entries.last?.id {
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
|
.sheet(item: $selectedEntry) { entry in
|
||||||
|
EntryDetailView(
|
||||||
|
entry: entry,
|
||||||
|
onDelete: { onDelete(entry) },
|
||||||
|
onUpdateQuantity: { _ in }
|
||||||
|
)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyMealView: some View {
|
||||||
|
Button(action: onAddFood) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(mealColor)
|
||||||
|
Text("Add food")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Swipe to Delete Row
|
||||||
|
|
||||||
|
struct SwipeToDeleteRow<Content: View>: View {
|
||||||
|
let onDelete: () -> Void
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
@State private var offset: CGFloat = 0
|
||||||
|
@State private var showDelete = false
|
||||||
|
|
||||||
|
private let deleteThreshold: CGFloat = -80
|
||||||
|
private let deleteWidth: CGFloat = 80
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .trailing) {
|
||||||
|
// Delete background
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
offset = -300
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
onDelete()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "trash.fill")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: deleteWidth, height: .infinity)
|
||||||
|
}
|
||||||
|
.frame(width: deleteWidth)
|
||||||
|
.background(Color.error)
|
||||||
|
}
|
||||||
|
.opacity(offset < 0 ? 1 : 0)
|
||||||
|
|
||||||
|
// Content
|
||||||
|
content()
|
||||||
|
.offset(x: offset)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 20)
|
||||||
|
.onChanged { value in
|
||||||
|
let translation = value.translation.width
|
||||||
|
if translation < 0 {
|
||||||
|
offset = translation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
if offset < deleteThreshold {
|
||||||
|
offset = -deleteWidth
|
||||||
|
showDelete = true
|
||||||
|
} else {
|
||||||
|
offset = 0
|
||||||
|
showDelete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
if showDelete {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
offset = 0
|
||||||
|
showDelete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry Row
|
||||||
|
|
||||||
|
struct EntryRow: View {
|
||||||
|
let entry: FoodEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Food icon or image
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
if let imageUrl = entry.imageUrl, !imageUrl.isEmpty {
|
||||||
|
AsyncImage(url: URL(string: imageUrl)) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} placeholder: {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name and serving
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.foodName)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Macros
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(Int(entry.calories))")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
+ Text(" kcal")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
macroTag("P", value: entry.protein, color: .proteinColor)
|
||||||
|
macroTag("C", value: entry.carbs, color: .carbsColor)
|
||||||
|
macroTag("F", value: entry.fat, color: .fatColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroTag(_ label: String, value: Double, color: Color) -> some View {
|
||||||
|
Text("\(label)\(Int(value))")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatQuantity(_ qty: Double) -> String {
|
||||||
|
if qty == qty.rounded() {
|
||||||
|
return "\(Int(qty))"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", qty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TemplatesView: View {
|
||||||
|
let dateString: String
|
||||||
|
let onTemplateLogged: () -> Void
|
||||||
|
|
||||||
|
@State private var viewModel = TemplatesViewModel()
|
||||||
|
@State private var confirmTemplate: MealTemplate?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
LoadingView(message: "Loading templates...")
|
||||||
|
.frame(height: 300)
|
||||||
|
} else if viewModel.templates.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "doc.text",
|
||||||
|
title: "No templates",
|
||||||
|
subtitle: "Create templates on the web app to quickly log meals"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyVStack(spacing: 16) {
|
||||||
|
ForEach(MealType.allCases) { meal in
|
||||||
|
let templates = viewModel.groupedTemplates[meal.rawValue] ?? []
|
||||||
|
if !templates.isEmpty {
|
||||||
|
templateSection(meal: meal, templates: templates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ungrouped
|
||||||
|
let ungrouped = viewModel.templates.filter { template in
|
||||||
|
!MealType.allCases.map(\.rawValue).contains(template.mealType)
|
||||||
|
}
|
||||||
|
if !ungrouped.isEmpty {
|
||||||
|
templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Log Template",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { confirmTemplate != nil },
|
||||||
|
set: { if !$0 { confirmTemplate = nil } }
|
||||||
|
),
|
||||||
|
presenting: confirmTemplate
|
||||||
|
) { template in
|
||||||
|
Button("Log \"\(template.name)\"") {
|
||||||
|
Task {
|
||||||
|
await viewModel.logTemplate(template, date: dateString) {
|
||||||
|
onTemplateLogged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { template in
|
||||||
|
Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func templateSection(meal: MealType, templates: [MealTemplate]) -> some View {
|
||||||
|
templateSection(
|
||||||
|
mealLabel: meal.displayName,
|
||||||
|
icon: meal.icon,
|
||||||
|
color: Color.mealColor(for: meal.rawValue),
|
||||||
|
templates: templates
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func templateSection(mealLabel: String, icon: String, color: Color, templates: [MealTemplate]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(mealLabel)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(templates) { template in
|
||||||
|
TemplateCard(
|
||||||
|
template: template,
|
||||||
|
isLogging: viewModel.loggedTemplateId == template.id
|
||||||
|
) {
|
||||||
|
confirmTemplate = template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateCard: View {
|
||||||
|
let template: MealTemplate
|
||||||
|
let isLogging: Bool
|
||||||
|
let onLog: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
// Icon
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.mealColor(for: template.mealType).opacity(0.1))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: "doc.text.fill")
|
||||||
|
.foregroundStyle(Color.mealColor(for: template.mealType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(template.name)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("\(Int(template.calories)) kcal")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.caloriesColor)
|
||||||
|
|
||||||
|
if let count = template.itemsCount {
|
||||||
|
Text("\(count) items")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let protein = template.protein {
|
||||||
|
Text("P\(Int(protein))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.proteinColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Log button
|
||||||
|
Button(action: onLog) {
|
||||||
|
if isLogging {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(Color.accentWarm)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isLogging)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TodayView: View {
|
||||||
|
@Bindable var viewModel: TodayViewModel
|
||||||
|
@Binding var showFoodSearch: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Date selector
|
||||||
|
dateSelector
|
||||||
|
|
||||||
|
if viewModel.isLoading {
|
||||||
|
LoadingView(message: "Loading entries...")
|
||||||
|
.frame(height: 200)
|
||||||
|
} else {
|
||||||
|
// Macro summary card
|
||||||
|
macroSummaryCard
|
||||||
|
|
||||||
|
// Meal sections
|
||||||
|
ForEach(viewModel.mealGroups) { group in
|
||||||
|
MealSectionView(
|
||||||
|
group: group,
|
||||||
|
isExpanded: viewModel.expandedMeals.contains(group.meal.rawValue),
|
||||||
|
onToggle: { viewModel.toggleMeal(group.meal.rawValue) },
|
||||||
|
onDelete: { entry in
|
||||||
|
Task { await viewModel.deleteEntry(entry) }
|
||||||
|
},
|
||||||
|
onAddFood: {
|
||||||
|
showFoodSearch = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom spacing for FAB
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating add button
|
||||||
|
addButton
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Selector
|
||||||
|
|
||||||
|
private var dateSelector: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Button {
|
||||||
|
viewModel.goToPreviousDay()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(viewModel.selectedDate.relativeLabel)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
if !viewModel.selectedDate.isToday {
|
||||||
|
Text(viewModel.selectedDate.displayString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.goToNextDay()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.foregroundStyle(
|
||||||
|
viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm
|
||||||
|
)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.selectedDate.isToday)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Macro Summary
|
||||||
|
|
||||||
|
private var macroSummaryCard: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Calories ring
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
MacroRingLarge(
|
||||||
|
current: viewModel.totalCalories,
|
||||||
|
goal: viewModel.goal.calories,
|
||||||
|
color: .caloriesColor,
|
||||||
|
size: 100,
|
||||||
|
lineWidth: 9
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
macroRow("Protein", current: viewModel.totalProtein, goal: viewModel.goal.protein, color: .proteinColor)
|
||||||
|
macroRow("Carbs", current: viewModel.totalCarbs, goal: viewModel.goal.carbs, color: .carbsColor)
|
||||||
|
macroRow("Fat", current: viewModel.totalFat, goal: viewModel.goal.fat, color: .fatColor)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroRow(_ label: String, current: Double, goal: Double, color: Color) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(current))/\(Int(goal))g")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
}
|
||||||
|
MacroBarCompact(current: current, goal: goal, color: color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Add Button
|
||||||
|
|
||||||
|
private var addButton: some View {
|
||||||
|
Button {
|
||||||
|
showFoodSearch = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
188
ios/Platform 1.44.16 AM/Platform/Features/Home/HomeView.swift
Normal file
188
ios/Platform 1.44.16 AM/Platform/Features/Home/HomeView.swift
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
@Environment(AuthManager.self) private var authManager
|
||||||
|
@State private var viewModel = HomeViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
LoadingView(message: "Loading dashboard...")
|
||||||
|
.frame(height: 300)
|
||||||
|
} else {
|
||||||
|
// Quick Stats Card
|
||||||
|
caloriesSummaryCard
|
||||||
|
|
||||||
|
// Macros Card
|
||||||
|
macrosCard
|
||||||
|
|
||||||
|
// Quick Actions
|
||||||
|
quickActionsCard
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationTitle("Dashboard")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
authManager.logout()
|
||||||
|
} label: {
|
||||||
|
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var caloriesSummaryCard: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Today")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text(Date().displayString)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.entryCount) entries")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.surfaceSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
MacroRingLarge(
|
||||||
|
current: viewModel.totalCalories,
|
||||||
|
goal: viewModel.goal.calories,
|
||||||
|
color: .caloriesColor,
|
||||||
|
size: 140,
|
||||||
|
lineWidth: 12
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal")
|
||||||
|
Spacer()
|
||||||
|
macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal")
|
||||||
|
Spacer()
|
||||||
|
macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var macrosCard: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Text("Macros")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
MacroRing(
|
||||||
|
current: viewModel.totalProtein,
|
||||||
|
goal: viewModel.goal.protein,
|
||||||
|
color: .proteinColor,
|
||||||
|
label: "Protein",
|
||||||
|
unit: "g",
|
||||||
|
size: 68
|
||||||
|
)
|
||||||
|
MacroRing(
|
||||||
|
current: viewModel.totalCarbs,
|
||||||
|
goal: viewModel.goal.carbs,
|
||||||
|
color: .carbsColor,
|
||||||
|
label: "Carbs",
|
||||||
|
unit: "g",
|
||||||
|
size: 68
|
||||||
|
)
|
||||||
|
MacroRing(
|
||||||
|
current: viewModel.totalFat,
|
||||||
|
goal: viewModel.goal.fat,
|
||||||
|
color: .fatColor,
|
||||||
|
label: "Fat",
|
||||||
|
unit: "g",
|
||||||
|
size: 68
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quickActionsCard: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Quick Actions")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald)
|
||||||
|
quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor)
|
||||||
|
quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroStat(_ label: String, value: Int, unit: String) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(value)")
|
||||||
|
.font(.system(.title3, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text("\(label)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func quickActionButton(icon: String, label: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(color.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class HomeViewModel {
|
||||||
|
var todayEntries: [FoodEntry] = []
|
||||||
|
var goal: DailyGoal = .defaultGoal
|
||||||
|
var isLoading = true
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
|
var totalCalories: Double {
|
||||||
|
todayEntries.reduce(0) { $0 + $1.calories }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProtein: Double {
|
||||||
|
todayEntries.reduce(0) { $0 + $1.protein }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCarbs: Double {
|
||||||
|
todayEntries.reduce(0) { $0 + $1.carbs }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFat: Double {
|
||||||
|
todayEntries.reduce(0) { $0 + $1.fat }
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryCount: Int {
|
||||||
|
todayEntries.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
let today = Date().apiDateString
|
||||||
|
|
||||||
|
do {
|
||||||
|
async let entriesTask = repo.entries(for: today, forceRefresh: true)
|
||||||
|
async let goalsTask = repo.goals(for: today)
|
||||||
|
|
||||||
|
todayEntries = try await entriesTask
|
||||||
|
goal = try await goalsTask
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ios/Platform 1.44.16 AM/Platform/PlatformApp.swift
Normal file
13
ios/Platform 1.44.16 AM/Platform/PlatformApp.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PlatformApp: App {
|
||||||
|
@State private var authManager = AuthManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(authManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoadingView: View {
|
||||||
|
var message: String = "Loading..."
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
.tint(Color.accentWarm)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorBanner: View {
|
||||||
|
let message: String
|
||||||
|
var onRetry: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Color.error)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let onRetry {
|
||||||
|
Button("Retry") {
|
||||||
|
onRetry()
|
||||||
|
}
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.error.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MacroBar: View {
|
||||||
|
let label: String
|
||||||
|
let current: Double
|
||||||
|
let goal: Double
|
||||||
|
let color: Color
|
||||||
|
var showGrams: Bool = true
|
||||||
|
|
||||||
|
private var progress: Double {
|
||||||
|
guard goal > 0 else { return 0 }
|
||||||
|
return min(current / goal, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
Spacer()
|
||||||
|
if showGrams {
|
||||||
|
Text("\(Int(current))g / \(Int(goal))g")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
} else {
|
||||||
|
Text("\(Int(current)) / \(Int(goal))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(color.opacity(0.12))
|
||||||
|
.frame(height: 6)
|
||||||
|
|
||||||
|
Capsule()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: geo.size.width * progress, height: 6)
|
||||||
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacroBarCompact: View {
|
||||||
|
let current: Double
|
||||||
|
let goal: Double
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
private var progress: Double {
|
||||||
|
guard goal > 0 else { return 0 }
|
||||||
|
return min(current / goal, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(color.opacity(0.12))
|
||||||
|
|
||||||
|
Capsule()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: geo.size.width * progress)
|
||||||
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MacroRing: View {
|
||||||
|
let current: Double
|
||||||
|
let goal: Double
|
||||||
|
let color: Color
|
||||||
|
let label: String
|
||||||
|
let unit: String
|
||||||
|
var size: CGFloat = 72
|
||||||
|
var lineWidth: CGFloat = 7
|
||||||
|
|
||||||
|
private var progress: Double {
|
||||||
|
guard goal > 0 else { return 0 }
|
||||||
|
return min(current / goal, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remaining: Double {
|
||||||
|
max(goal - current, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress)
|
||||||
|
.stroke(
|
||||||
|
color,
|
||||||
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(Int(remaining))")
|
||||||
|
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text("left")
|
||||||
|
.font(.system(size: size * 0.13, weight: .medium))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacroRingLarge: View {
|
||||||
|
let current: Double
|
||||||
|
let goal: Double
|
||||||
|
let color: Color
|
||||||
|
var size: CGFloat = 120
|
||||||
|
var lineWidth: CGFloat = 10
|
||||||
|
|
||||||
|
private var progress: Double {
|
||||||
|
guard goal > 0 else { return 0 }
|
||||||
|
return min(current / goal, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remaining: Double {
|
||||||
|
max(goal - current, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress)
|
||||||
|
.stroke(
|
||||||
|
color,
|
||||||
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(Int(remaining))")
|
||||||
|
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text("remaining")
|
||||||
|
.font(.system(size: size * 0.11, weight: .medium))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
// Warm palette matching web app
|
||||||
|
static let canvas = Color(hex: "F5EFE6")
|
||||||
|
static let surface = Color.white
|
||||||
|
static let surfaceSecondary = Color(hex: "F4F4F5")
|
||||||
|
static let cardBackground = Color.white
|
||||||
|
static let cardSecondary = Color(hex: "F4F4F5")
|
||||||
|
|
||||||
|
static let text1 = Color(hex: "18181B")
|
||||||
|
static let text2 = Color(hex: "3F3F46")
|
||||||
|
static let text3 = Color(hex: "71717A")
|
||||||
|
static let text4 = Color(hex: "A1A1AA")
|
||||||
|
|
||||||
|
// Accent — warm amber/brown
|
||||||
|
static let accentWarm = Color(hex: "8B6914")
|
||||||
|
static let accentWarmBg = Color(hex: "FEF7E6")
|
||||||
|
|
||||||
|
// Emerald accent from web
|
||||||
|
static let accentEmerald = Color(hex: "059669")
|
||||||
|
static let accentEmeraldBg = Color(hex: "ECFDF5")
|
||||||
|
|
||||||
|
// Semantic
|
||||||
|
static let success = Color(hex: "059669")
|
||||||
|
static let error = Color(hex: "DC2626")
|
||||||
|
static let warning = Color(hex: "D97706")
|
||||||
|
|
||||||
|
// Macro colors
|
||||||
|
static let caloriesColor = Color(hex: "8B6914")
|
||||||
|
static let proteinColor = Color(hex: "059669")
|
||||||
|
static let carbsColor = Color(hex: "3B82F6")
|
||||||
|
static let fatColor = Color(hex: "F59E0B")
|
||||||
|
static let sugarColor = Color(hex: "EC4899")
|
||||||
|
static let fiberColor = Color(hex: "8B5CF6")
|
||||||
|
|
||||||
|
// Meal colors
|
||||||
|
static let breakfast = Color(hex: "F59E0B")
|
||||||
|
static let lunch = Color(hex: "059669")
|
||||||
|
static let dinner = Color(hex: "3B82F6")
|
||||||
|
static let snack = Color(hex: "8B5CF6")
|
||||||
|
|
||||||
|
init(hex: String) {
|
||||||
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var int: UInt64 = 0
|
||||||
|
Scanner(string: hex).scanHexInt64(&int)
|
||||||
|
let a, r, g, b: UInt64
|
||||||
|
switch hex.count {
|
||||||
|
case 3:
|
||||||
|
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||||
|
case 6:
|
||||||
|
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
case 8:
|
||||||
|
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
default:
|
||||||
|
(a, r, g, b) = (255, 0, 0, 0)
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double(r) / 255,
|
||||||
|
green: Double(g) / 255,
|
||||||
|
blue: Double(b) / 255,
|
||||||
|
opacity: Double(a) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mealColor(for meal: String) -> Color {
|
||||||
|
switch meal.lowercased() {
|
||||||
|
case "breakfast": return .breakfast
|
||||||
|
case "lunch": return .lunch
|
||||||
|
case "dinner": return .dinner
|
||||||
|
case "snack": return .snack
|
||||||
|
default: return .text3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mealIcon(for meal: String) -> String {
|
||||||
|
switch meal.lowercased() {
|
||||||
|
case "breakfast": return "sunrise.fill"
|
||||||
|
case "lunch": return "sun.max.fill"
|
||||||
|
case "dinner": return "moon.fill"
|
||||||
|
case "snack": return "leaf.fill"
|
||||||
|
default: return "fork.knife"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
/// Format as yyyy-MM-dd for API calls
|
||||||
|
var apiDateString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display format: "Mon, Apr 2"
|
||||||
|
var displayString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEE, MMM d"
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full display: "Monday, April 2, 2026"
|
||||||
|
var fullDisplayString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .full
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short display: "Apr 2"
|
||||||
|
var shortDisplayString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM d"
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool {
|
||||||
|
Calendar.current.isDateInToday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isYesterday: Bool {
|
||||||
|
Calendar.current.isDateInYesterday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(days: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(apiString: String) -> Date? {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return formatter.date(from: apiString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a label like "Today", "Yesterday", or the display string
|
||||||
|
var relativeLabel: String {
|
||||||
|
if isToday { return "Today" }
|
||||||
|
if isYesterday { return "Yesterday" }
|
||||||
|
return displayString
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Config {
|
enum Config {
|
||||||
static let gatewayURL = "https://dash.quadjourney.com"
|
static let gatewayURL: URL = {
|
||||||
|
if let override = UserDefaults.standard.string(forKey: "gateway_url"),
|
||||||
|
let url = URL(string: override) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return URL(string: "https://dash.quadjourney.com")!
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func apiURL(_ path: String) -> URL {
|
||||||
|
gatewayURL.appendingPathComponent(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ struct ContentView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if authManager.isCheckingAuth {
|
if authManager.isCheckingAuth {
|
||||||
ProgressView("Loading...")
|
LoadingView(message: "Checking session...")
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
} else if authManager.isAuthenticated {
|
||||||
.background(Color.canvas)
|
|
||||||
} else if authManager.isLoggedIn {
|
|
||||||
MainTabView()
|
MainTabView()
|
||||||
} else {
|
} else {
|
||||||
LoginView()
|
LoginView()
|
||||||
@@ -22,22 +20,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@State private var selectedTab = 0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView {
|
||||||
HomeView()
|
HomeView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Home", systemImage: "house.fill")
|
Label("Home", systemImage: "house.fill")
|
||||||
}
|
}
|
||||||
.tag(0)
|
|
||||||
|
|
||||||
FitnessTabView()
|
FitnessTabView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Fitness", systemImage: "figure.run")
|
Label("Fitness", systemImage: "flame.fill")
|
||||||
}
|
}
|
||||||
.tag(1)
|
|
||||||
}
|
}
|
||||||
.tint(.accentWarm)
|
.tint(Color.accentWarm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,79 +2,157 @@ import SwiftUI
|
|||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@Environment(AuthManager.self) private var authManager
|
@Environment(AuthManager.self) private var authManager
|
||||||
|
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
|
private enum Field: Hashable {
|
||||||
|
case username, password
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
ZStack {
|
||||||
Spacer()
|
Color.canvas
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
ScrollView {
|
||||||
Image(systemName: "square.grid.2x2.fill")
|
VStack(spacing: 32) {
|
||||||
.font(.system(size: 48))
|
Spacer()
|
||||||
.foregroundStyle(Color.accentWarm)
|
.frame(height: 60)
|
||||||
Text("Platform")
|
|
||||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
Text("Sign in to continue")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
// Logo / Branding
|
||||||
TextField("Username", text: $username)
|
VStack(spacing: 8) {
|
||||||
.textFieldStyle(.plain)
|
Image(systemName: "square.grid.2x2.fill")
|
||||||
.padding(14)
|
.font(.system(size: 48))
|
||||||
.background(Color.surfaceSecondary)
|
.foregroundStyle(Color.accentWarm)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.textContentType(.username)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
|
|
||||||
SecureField("Password", text: $password)
|
Text("Platform")
|
||||||
.textFieldStyle(.plain)
|
.font(.largeTitle.weight(.bold))
|
||||||
.padding(14)
|
.foregroundStyle(Color.text1)
|
||||||
.background(Color.surfaceSecondary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.textContentType(.password)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
|
|
||||||
if let error = authManager.loginError {
|
Text("Sign in to your dashboard")
|
||||||
Text(error)
|
.font(.subheadline)
|
||||||
.font(.caption)
|
.foregroundStyle(Color.text3)
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
isLoading = true
|
|
||||||
Task {
|
|
||||||
await authManager.login(username: username, password: password)
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Group {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.tint(.white)
|
|
||||||
} else {
|
|
||||||
Text("Sign In")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 48)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(Color.accentWarm)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
|
||||||
|
|
||||||
Spacer()
|
// Form
|
||||||
Spacer()
|
VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Username")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
TextField("Enter username", text: $username)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.textContentType(.username)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .username)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(
|
||||||
|
focusedField == .username ? Color.accentWarm : Color.black.opacity(0.06),
|
||||||
|
lineWidth: focusedField == .username ? 2 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Password")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
SecureField("Enter password", text: $password)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(
|
||||||
|
focusedField == .password ? Color.accentWarm : Color.black.opacity(0.06),
|
||||||
|
lineWidth: focusedField == .password ? 2 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = authManager.loginError {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.error)
|
||||||
|
Text(error)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.error)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
|
// Sign In Button
|
||||||
|
Button {
|
||||||
|
performLogin()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
Text("Sign In")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(canSubmit ? Color.accentWarm : Color.text4)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSubmit {
|
||||||
|
switch focusedField {
|
||||||
|
case .username:
|
||||||
|
focusedField = .password
|
||||||
|
case .password:
|
||||||
|
performLogin()
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
!username.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !password.isEmpty
|
||||||
|
&& !isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performLogin() {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
isLoading = true
|
||||||
|
focusedField = nil
|
||||||
|
Task {
|
||||||
|
await authManager.login(
|
||||||
|
username: username.trimmingCharacters(in: .whitespaces),
|
||||||
|
password: password
|
||||||
|
)
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
.background(Color.canvas.ignoresSafeArea())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,108 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Observable
|
@MainActor @Observable
|
||||||
final class FoodSearchViewModel {
|
final class FoodSearchViewModel {
|
||||||
var query = ""
|
var searchText = ""
|
||||||
var results: [FoodItem] = []
|
var searchResults: [FoodItem] = []
|
||||||
var recentFoods: [FoodItem] = []
|
var recentFoods: [FoodItem] = []
|
||||||
var allFoods: [FoodItem] = []
|
|
||||||
var isSearching = false
|
var isSearching = false
|
||||||
var isLoadingInitial = false
|
var isLoadingRecent = false
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
private let api = FitnessAPI()
|
// Add food sheet state
|
||||||
|
var selectedFood: FoodItem?
|
||||||
|
var showAddSheet = false
|
||||||
|
var addQuantity: Double = 1
|
||||||
|
var addMealType: MealType = .guess()
|
||||||
|
var isAddingFood = false
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
func loadInitial() async {
|
var displayedFoods: [FoodItem] {
|
||||||
isLoadingInitial = true
|
if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
do {
|
return recentFoods
|
||||||
async let r = api.getRecentFoods()
|
|
||||||
async let a = api.getFoods()
|
|
||||||
recentFoods = try await r
|
|
||||||
allFoods = try await a
|
|
||||||
} catch {
|
|
||||||
// Silently fail
|
|
||||||
}
|
}
|
||||||
isLoadingInitial = false
|
return searchResults
|
||||||
|
}
|
||||||
|
|
||||||
|
var isShowingRecent: Bool {
|
||||||
|
searchText.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecent() async {
|
||||||
|
isLoadingRecent = true
|
||||||
|
do {
|
||||||
|
recentFoods = try await repo.recentFoods(forceRefresh: true)
|
||||||
|
} catch {
|
||||||
|
// Silent failure for recent foods
|
||||||
|
}
|
||||||
|
isLoadingRecent = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func search() {
|
func search() {
|
||||||
|
let query = searchText.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Cancel previous search
|
||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
let q = query.trimmingCharacters(in: .whitespaces)
|
|
||||||
guard q.count >= 2 else {
|
guard !query.isEmpty else {
|
||||||
results = []
|
searchResults = []
|
||||||
isSearching = false
|
isSearching = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard query.count >= 2 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isSearching = true
|
isSearching = true
|
||||||
searchTask = Task {
|
searchTask = Task {
|
||||||
|
// Debounce
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let items = try await api.searchFoods(query: q)
|
let results = try await repo.searchFoods(query: query)
|
||||||
if !Task.isCancelled {
|
guard !Task.isCancelled else { return }
|
||||||
results = items
|
searchResults = results
|
||||||
isSearching = false
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if !Task.isCancelled {
|
guard !Task.isCancelled else { return }
|
||||||
isSearching = false
|
errorMessage = error.localizedDescription
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
isSearching = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectFood(_ food: FoodItem) {
|
||||||
|
selectedFood = food
|
||||||
|
addQuantity = 1
|
||||||
|
addMealType = .guess()
|
||||||
|
showAddSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFood(date: String, onComplete: @escaping () -> Void) async {
|
||||||
|
guard let food = selectedFood else { return }
|
||||||
|
isAddingFood = true
|
||||||
|
|
||||||
|
let request = CreateEntryRequest(
|
||||||
|
foodId: food.id,
|
||||||
|
quantity: addQuantity,
|
||||||
|
unit: food.baseUnit ?? "serving",
|
||||||
|
mealType: addMealType.rawValue,
|
||||||
|
entryDate: date,
|
||||||
|
entryMethod: "manual",
|
||||||
|
source: "ios_app"
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await repo.createEntry(request)
|
||||||
|
showAddSheet = false
|
||||||
|
selectedFood = nil
|
||||||
|
onComplete()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to add food: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
isAddingFood = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Observable
|
@MainActor @Observable
|
||||||
final class GoalsViewModel {
|
final class GoalsViewModel {
|
||||||
var calories: String = ""
|
var goal: DailyGoal = .defaultGoal
|
||||||
var protein: String = ""
|
var isLoading = true
|
||||||
var carbs: String = ""
|
var errorMessage: String?
|
||||||
var fat: String = ""
|
|
||||||
var sugar: String = ""
|
|
||||||
var fiber: String = ""
|
|
||||||
var isLoading = false
|
|
||||||
var isSaving = false
|
|
||||||
var error: String?
|
|
||||||
var saved = false
|
|
||||||
|
|
||||||
private let api = FitnessAPI()
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
func load(date: String) async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let goals = try await api.getGoals(date: date)
|
goal = try await repo.goals(for: Date().apiDateString, forceRefresh: true)
|
||||||
calories = String(Int(goals.calories))
|
|
||||||
protein = String(Int(goals.protein))
|
|
||||||
carbs = String(Int(goals.carbs))
|
|
||||||
fat = String(Int(goals.fat))
|
|
||||||
sugar = String(Int(goals.sugar))
|
|
||||||
fiber = String(Int(goals.fiber))
|
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() async {
|
|
||||||
isSaving = true
|
|
||||||
error = nil
|
|
||||||
saved = false
|
|
||||||
let req = UpdateGoalsRequest(
|
|
||||||
calories: Double(calories) ?? 2000,
|
|
||||||
protein: Double(protein) ?? 150,
|
|
||||||
carbs: Double(carbs) ?? 250,
|
|
||||||
fat: Double(fat) ?? 65,
|
|
||||||
sugar: Double(sugar) ?? 50,
|
|
||||||
fiber: Double(fiber) ?? 30
|
|
||||||
)
|
|
||||||
do {
|
|
||||||
_ = try await api.updateGoals(req)
|
|
||||||
saved = true
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isSaving = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class HistoryViewModel {
|
||||||
|
var days: [HistoryDay] = []
|
||||||
|
var isLoading = true
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
private let numberOfDays = 14
|
||||||
|
|
||||||
|
struct HistoryDay: Identifiable {
|
||||||
|
let date: Date
|
||||||
|
let dateString: String
|
||||||
|
let entries: [FoodEntry]
|
||||||
|
let goal: DailyGoal
|
||||||
|
|
||||||
|
var id: String { dateString }
|
||||||
|
|
||||||
|
var totalCalories: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.calories }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProtein: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.protein }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCarbs: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.carbs }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFat: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.fat }
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryCount: Int {
|
||||||
|
entries.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var calorieProgress: Double {
|
||||||
|
guard goal.calories > 0 else { return 0 }
|
||||||
|
return min(totalCalories / goal.calories, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
var results: [HistoryDay] = []
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Load past N days
|
||||||
|
for i in 0..<numberOfDays {
|
||||||
|
let date = Date().adding(days: -i)
|
||||||
|
let dateString = date.apiDateString
|
||||||
|
|
||||||
|
let entries = try await repo.entries(for: dateString, forceRefresh: i == 0)
|
||||||
|
let goal = try await repo.goals(for: dateString)
|
||||||
|
|
||||||
|
results.append(HistoryDay(
|
||||||
|
date: date,
|
||||||
|
dateString: dateString,
|
||||||
|
entries: entries,
|
||||||
|
goal: goal
|
||||||
|
))
|
||||||
|
}
|
||||||
|
days = results
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
days = results // Show what we have
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,45 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Observable
|
@MainActor @Observable
|
||||||
final class TemplatesViewModel {
|
final class TemplatesViewModel {
|
||||||
var templates: [MealTemplate] = []
|
var templates: [MealTemplate] = []
|
||||||
var isLoading = false
|
var isLoading = true
|
||||||
var error: String?
|
var errorMessage: String?
|
||||||
var logSuccess: String?
|
var isLogging = false
|
||||||
|
var loggedTemplateId: String?
|
||||||
|
|
||||||
private let api = FitnessAPI()
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
templates = try await api.getTemplates()
|
templates = try await repo.templates(forceRefresh: true)
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func logTemplate(_ template: MealTemplate, date: String) async {
|
func logTemplate(_ template: MealTemplate, date: String, onComplete: @escaping () -> Void) async {
|
||||||
|
isLogging = true
|
||||||
|
loggedTemplateId = template.id
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await api.logTemplate(id: template.id, date: date)
|
try await repo.logTemplate(id: template.id, date: date)
|
||||||
logSuccess = "Logged \(template.name)"
|
loggedTemplateId = nil
|
||||||
// Refresh the repository
|
onComplete()
|
||||||
await FitnessRepository.shared.loadDay(date: date)
|
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
errorMessage = "Failed to log template: \(error.localizedDescription)"
|
||||||
|
loggedTemplateId = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isLogging = false
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupedByMeal: [MealType: [MealTemplate]] {
|
var groupedTemplates: [String: [MealTemplate]] {
|
||||||
Dictionary(grouping: templates, by: { $0.mealType })
|
Dictionary(grouping: templates, by: \.mealType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,111 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Observable
|
@MainActor @Observable
|
||||||
final class TodayViewModel {
|
final class TodayViewModel {
|
||||||
|
var entries: [FoodEntry] = []
|
||||||
|
var goal: DailyGoal = .defaultGoal
|
||||||
var selectedDate: Date = Date()
|
var selectedDate: Date = Date()
|
||||||
let repository = FitnessRepository.shared
|
var isLoading = true
|
||||||
|
var errorMessage: String?
|
||||||
|
var expandedMeals: Set<String> = Set(MealType.allCases.map(\.rawValue))
|
||||||
|
|
||||||
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
var dateString: String {
|
var dateString: String {
|
||||||
selectedDate.apiDateString
|
selectedDate.apiDateString
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayDate: String {
|
var mealGroups: [MealGroup] {
|
||||||
if selectedDate.isToday {
|
MealType.allCases.map { meal in
|
||||||
return "Today"
|
MealGroup(
|
||||||
|
meal: meal,
|
||||||
|
entries: entries.filter { $0.mealType == meal.rawValue }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return selectedDate.displayString
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var totalCalories: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.calories }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProtein: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.protein }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCarbs: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.carbs }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFat: Double {
|
||||||
|
entries.reduce(0) { $0 + $1.fat }
|
||||||
|
}
|
||||||
|
|
||||||
|
var caloriesRemaining: Double {
|
||||||
|
max(goal.calories - totalCalories, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
await repository.loadDay(date: dateString)
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
async let entriesTask = repo.entries(for: dateString, forceRefresh: true)
|
||||||
|
async let goalsTask = repo.goals(for: dateString)
|
||||||
|
|
||||||
|
entries = try await entriesTask
|
||||||
|
goal = try await goalsTask
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func previousDay() {
|
func goToNextDay() {
|
||||||
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
|
selectedDate = selectedDate.adding(days: 1)
|
||||||
Task { await load() }
|
Task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextDay() {
|
func goToPreviousDay() {
|
||||||
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
|
selectedDate = selectedDate.adding(days: -1)
|
||||||
Task { await load() }
|
Task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func goToToday() {
|
||||||
|
selectedDate = Date()
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMeal(_ meal: String) {
|
||||||
|
if expandedMeals.contains(meal) {
|
||||||
|
expandedMeals.remove(meal)
|
||||||
|
} else {
|
||||||
|
expandedMeals.insert(meal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func deleteEntry(_ entry: FoodEntry) async {
|
func deleteEntry(_ entry: FoodEntry) async {
|
||||||
await repository.deleteEntry(id: entry.id)
|
// Optimistic removal
|
||||||
|
entries.removeAll { $0.id == entry.id }
|
||||||
|
do {
|
||||||
|
try await repo.deleteEntry(id: entry.id, date: dateString)
|
||||||
|
} catch {
|
||||||
|
// Reload on failure
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEntryQuantity(id: String, quantity: Double) async {
|
||||||
|
let request = UpdateEntryRequest(quantity: quantity)
|
||||||
|
do {
|
||||||
|
_ = try await repo.updateEntry(id: id, request: request, date: dateString)
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to update entry"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,170 +2,238 @@ import SwiftUI
|
|||||||
|
|
||||||
struct AddFoodSheet: View {
|
struct AddFoodSheet: View {
|
||||||
let food: FoodItem
|
let food: FoodItem
|
||||||
let mealType: MealType
|
@Binding var quantity: Double
|
||||||
let dateString: String
|
@Binding var mealType: MealType
|
||||||
let onAdded: () -> Void
|
let isAdding: Bool
|
||||||
|
let onAdd: () -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var quantity: Double = 1.0
|
@State private var quantityText: String = "1"
|
||||||
@State private var selectedMeal: MealType
|
|
||||||
@State private var isAdding = false
|
|
||||||
|
|
||||||
init(food: FoodItem, mealType: MealType, dateString: String, onAdded: @escaping () -> Void) {
|
|
||||||
self.food = food
|
|
||||||
self.mealType = mealType
|
|
||||||
self.dateString = dateString
|
|
||||||
self.onAdded = onAdded
|
|
||||||
_selectedMeal = State(initialValue: mealType)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var scaledCalories: Double { food.calories * quantity }
|
|
||||||
private var scaledProtein: Double { food.protein * quantity }
|
|
||||||
private var scaledCarbs: Double { food.carbs * quantity }
|
|
||||||
private var scaledFat: Double { food.fat * quantity }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
VStack(spacing: 24) {
|
||||||
VStack(spacing: 20) {
|
// Food info header
|
||||||
// Food header
|
foodHeader
|
||||||
VStack(spacing: 8) {
|
|
||||||
if let img = food.imageFilename {
|
|
||||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
|
||||||
image.resizable().aspectRatio(contentMode: .fill)
|
|
||||||
} placeholder: {
|
|
||||||
Color.surfaceSecondary
|
|
||||||
}
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(food.name)
|
// Quantity input
|
||||||
.font(.title3.bold())
|
quantitySection
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
|
|
||||||
if let serving = food.servingSize {
|
// Meal picker
|
||||||
Text(serving)
|
mealPickerSection
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
// Macro preview
|
||||||
|
macroPreview
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Add button
|
||||||
|
Button(action: onAdd) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isAdding {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.white)
|
||||||
}
|
}
|
||||||
|
Text("Add to \(mealType.displayName)")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
// Quantity
|
.padding(.vertical, 16)
|
||||||
VStack(spacing: 8) {
|
.background(Color.accentWarm)
|
||||||
Text("Quantity")
|
.foregroundStyle(.white)
|
||||||
.font(.subheadline.bold())
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
|
||||||
Image(systemName: "minus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
Text(String(format: "%.1f", quantity))
|
|
||||||
.font(.title2.bold())
|
|
||||||
.frame(minWidth: 60)
|
|
||||||
Button { quantity += 0.5 } label: {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meal picker
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Text("Meal")
|
|
||||||
.font(.subheadline.bold())
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(MealType.allCases) { meal in
|
|
||||||
Button {
|
|
||||||
selectedMeal = meal
|
|
||||||
} label: {
|
|
||||||
Text(meal.displayName)
|
|
||||||
.font(.caption.bold())
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(selectedMeal == meal ? meal.color.opacity(0.2) : Color.surfaceSecondary)
|
|
||||||
.foregroundStyle(selectedMeal == meal ? meal.color : Color.textSecondary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nutrition preview
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
nutritionRow("Calories", "\(Int(scaledCalories))")
|
|
||||||
nutritionRow("Protein", "\(Int(scaledProtein))g")
|
|
||||||
nutritionRow("Carbs", "\(Int(scaledCarbs))g")
|
|
||||||
nutritionRow("Fat", "\(Int(scaledFat))g")
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(Color.surfaceSecondary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
|
|
||||||
// Add button
|
|
||||||
Button {
|
|
||||||
isAdding = true
|
|
||||||
Task {
|
|
||||||
let req = CreateEntryRequest(
|
|
||||||
foodId: food.id,
|
|
||||||
foodName: food.name,
|
|
||||||
mealType: selectedMeal.rawValue,
|
|
||||||
quantity: quantity,
|
|
||||||
entryDate: dateString,
|
|
||||||
calories: food.calories,
|
|
||||||
protein: food.protein,
|
|
||||||
carbs: food.carbs,
|
|
||||||
fat: food.fat,
|
|
||||||
sugar: food.sugar,
|
|
||||||
fiber: food.fiber
|
|
||||||
)
|
|
||||||
await FitnessRepository.shared.addEntry(req)
|
|
||||||
isAdding = false
|
|
||||||
dismiss()
|
|
||||||
onAdded()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Group {
|
|
||||||
if isAdding {
|
|
||||||
ProgressView().tint(.white)
|
|
||||||
} else {
|
|
||||||
Text("Add")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 48)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(Color.accentWarm)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
.padding(20)
|
.disabled(isAdding || quantity <= 0)
|
||||||
}
|
}
|
||||||
|
.padding(20)
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.navigationTitle("Add Food")
|
.navigationTitle("Add Food")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("Cancel") { dismiss() }
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
quantityText = formatQuantity(quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foodHeader: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.accentWarmBg)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(food.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text("\(Int(food.caloriesPerBase)) kcal per \(food.displayUnit)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quantitySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Quantity (\(food.displayUnit))")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Decrement
|
||||||
|
Button {
|
||||||
|
adjustQuantity(by: -0.5)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(quantity > 0.5 ? Color.accentWarm : Color.text4)
|
||||||
|
}
|
||||||
|
.disabled(quantity <= 0.5)
|
||||||
|
|
||||||
|
// Text field
|
||||||
|
TextField("1", text: $quantityText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(width: 80)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.onChange(of: quantityText) {
|
||||||
|
if let val = Double(quantityText), val > 0 {
|
||||||
|
quantity = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment
|
||||||
|
Button {
|
||||||
|
adjustQuantity(by: 0.5)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Quick presets
|
||||||
|
ForEach([0.5, 1.0, 2.0], id: \.self) { preset in
|
||||||
|
Button {
|
||||||
|
quantity = preset
|
||||||
|
quantityText = formatQuantity(preset)
|
||||||
|
} label: {
|
||||||
|
Text(formatQuantity(preset))
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(quantity == preset ? .white : Color.text2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(quantity == preset ? Color.accentWarm : Color.surfaceSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nutritionRow(_ label: String, _ value: String) -> some View {
|
private var mealPickerSection: some View {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(label)
|
Text("Meal")
|
||||||
.font(.subheadline)
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.text3)
|
||||||
Spacer()
|
.textCase(.uppercase)
|
||||||
Text(value)
|
|
||||||
.font(.subheadline.bold())
|
HStack(spacing: 8) {
|
||||||
.foregroundStyle(Color.textPrimary)
|
ForEach(MealType.allCases) { meal in
|
||||||
|
Button {
|
||||||
|
mealType = meal
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: meal.icon)
|
||||||
|
.font(.body)
|
||||||
|
Text(meal.displayName)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(mealType == meal ? .white : Color.text2)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
mealType == meal
|
||||||
|
? Color.mealColor(for: meal.rawValue)
|
||||||
|
: Color.surfaceSecondary
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var macroPreview: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Nutrition Preview")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
macroPreviewItem("Calories", value: food.scaledCalories(quantity: quantity), unit: "kcal", color: .caloriesColor)
|
||||||
|
Spacer()
|
||||||
|
macroPreviewItem("Protein", value: food.scaledProtein(quantity: quantity), unit: "g", color: .proteinColor)
|
||||||
|
Spacer()
|
||||||
|
macroPreviewItem("Carbs", value: food.scaledCarbs(quantity: quantity), unit: "g", color: .carbsColor)
|
||||||
|
Spacer()
|
||||||
|
macroPreviewItem("Fat", value: food.scaledFat(quantity: quantity), unit: "g", color: .fatColor)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroPreviewItem(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(.system(.title3, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adjustQuantity(by amount: Double) {
|
||||||
|
quantity = max(0.5, quantity + amount)
|
||||||
|
quantityText = formatQuantity(quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatQuantity(_ qty: Double) -> String {
|
||||||
|
if qty == qty.rounded() {
|
||||||
|
return "\(Int(qty))"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", qty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,159 +2,257 @@ import SwiftUI
|
|||||||
|
|
||||||
struct EntryDetailView: View {
|
struct EntryDetailView: View {
|
||||||
let entry: FoodEntry
|
let entry: FoodEntry
|
||||||
let dateString: String
|
let onDelete: () -> Void
|
||||||
|
let onUpdateQuantity: (Double) -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var quantity: Double
|
@State private var editQuantity: String
|
||||||
@State private var isDeleting = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var isSaving = false
|
|
||||||
|
|
||||||
init(entry: FoodEntry, dateString: String) {
|
init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.dateString = dateString
|
self.onDelete = onDelete
|
||||||
_quantity = State(initialValue: entry.quantity)
|
self.onUpdateQuantity = onUpdateQuantity
|
||||||
|
_editQuantity = State(initialValue: entry.quantity == entry.quantity.rounded() ? "\(Int(entry.quantity))" : String(format: "%.1f", entry.quantity))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var scaledCalories: Double { entry.calories * quantity }
|
|
||||||
private var scaledProtein: Double { entry.protein * quantity }
|
|
||||||
private var scaledCarbs: Double { entry.carbs * quantity }
|
|
||||||
private var scaledFat: Double { entry.fat * quantity }
|
|
||||||
private var scaledSugar: Double? { entry.sugar.map { $0 * quantity } }
|
|
||||||
private var scaledFiber: Double? { entry.fiber.map { $0 * quantity } }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Food name
|
// Header
|
||||||
Text(entry.foodName)
|
entryHeader
|
||||||
.font(.title2.bold())
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
|
|
||||||
// Meal badge
|
|
||||||
HStack {
|
|
||||||
Image(systemName: entry.mealType.icon)
|
|
||||||
Text(entry.mealType.displayName)
|
|
||||||
}
|
|
||||||
.font(.caption.bold())
|
|
||||||
.foregroundStyle(entry.mealType.color)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(entry.mealType.color.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
|
|
||||||
// Quantity editor
|
// Quantity editor
|
||||||
VStack(spacing: 8) {
|
quantityEditor
|
||||||
Text("Quantity")
|
|
||||||
.font(.subheadline.bold())
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
|
||||||
Image(systemName: "minus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
Text(String(format: "%.1f", quantity))
|
|
||||||
.font(.title2.bold())
|
|
||||||
.frame(minWidth: 60)
|
|
||||||
Button { quantity += 0.5 } label: {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if quantity != entry.quantity {
|
// Macros grid
|
||||||
Button {
|
macrosGrid
|
||||||
isSaving = true
|
|
||||||
Task {
|
// Details
|
||||||
_ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity)
|
detailsSection
|
||||||
isSaving = false
|
|
||||||
dismiss()
|
// Delete button
|
||||||
}
|
Button(role: .destructive) {
|
||||||
} label: {
|
showDeleteConfirm = true
|
||||||
Group {
|
} label: {
|
||||||
if isSaving {
|
HStack(spacing: 8) {
|
||||||
ProgressView().tint(.white)
|
Image(systemName: "trash")
|
||||||
} else {
|
Text("Delete Entry")
|
||||||
Text("Update Quantity")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 44)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.font(.body.weight(.medium))
|
||||||
.tint(Color.accentWarm)
|
.foregroundStyle(Color.error)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Color.error.opacity(0.06))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nutrition grid
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
|
||||||
nutritionCell("Calories", "\(Int(scaledCalories))", "kcal")
|
|
||||||
nutritionCell("Protein", "\(Int(scaledProtein))", "g")
|
|
||||||
nutritionCell("Carbs", "\(Int(scaledCarbs))", "g")
|
|
||||||
nutritionCell("Fat", "\(Int(scaledFat))", "g")
|
|
||||||
if let sugar = scaledSugar {
|
|
||||||
nutritionCell("Sugar", "\(Int(sugar))", "g")
|
|
||||||
}
|
|
||||||
if let fiber = scaledFiber {
|
|
||||||
nutritionCell("Fiber", "\(Int(fiber))", "g")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
Button(role: .destructive) {
|
|
||||||
isDeleting = true
|
|
||||||
Task {
|
|
||||||
await FitnessRepository.shared.deleteEntry(id: entry.id)
|
|
||||||
isDeleting = false
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
if isDeleting {
|
|
||||||
ProgressView().tint(.red)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
Text("Delete Entry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 44)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.tint(.red)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.navigationTitle("Entry Detail")
|
.navigationTitle("Entry Details")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button("Done") { dismiss() }
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.confirmationDialog("Delete Entry", isPresented: $showDeleteConfirm) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
onDelete()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to delete \"\(entry.foodName)\"?")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nutritionCell(_ label: String, _ value: String, _ unit: String) -> some View {
|
private var entryHeader: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
|
||||||
|
Image(systemName: Color.mealIcon(for: entry.mealType))
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(entry.foodName)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(entry.mealType.capitalized)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.mealColor(for: entry.mealType).opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quantityEditor: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Quantity")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
let current = Double(editQuantity) ?? 1
|
||||||
|
let newVal = max(0.5, current - 0.5)
|
||||||
|
editQuantity = formatQuantity(newVal)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("1", text: $editQuantity)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(width: 80)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
let current = Double(editQuantity) ?? 1
|
||||||
|
editQuantity = formatQuantity(current + 0.5)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Save") {
|
||||||
|
if let qty = Double(editQuantity), qty > 0 {
|
||||||
|
onUpdateQuantity(qty)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var macrosGrid: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Nutrition")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 12) {
|
||||||
|
macroCell("Calories", value: entry.calories, unit: "kcal", color: .caloriesColor)
|
||||||
|
macroCell("Protein", value: entry.protein, unit: "g", color: .proteinColor)
|
||||||
|
macroCell("Carbs", value: entry.carbs, unit: "g", color: .carbsColor)
|
||||||
|
macroCell("Fat", value: entry.fat, unit: "g", color: .fatColor)
|
||||||
|
if let sugar = entry.sugar {
|
||||||
|
macroCell("Sugar", value: sugar, unit: "g", color: .sugarColor)
|
||||||
|
}
|
||||||
|
if let fiber = entry.fiber {
|
||||||
|
macroCell("Fiber", value: fiber, unit: "g", color: .fiberColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text(value)
|
Text("\(Int(value))")
|
||||||
.font(.title3.bold())
|
.font(.title3.weight(.bold))
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(color)
|
||||||
Text("\(label) (\(unit))")
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption2)
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.text3)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(12)
|
.padding(.vertical, 10)
|
||||||
.background(Color.surfaceSecondary)
|
.background(color.opacity(0.06))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var detailsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Details")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
detailRow("Serving", value: entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
|
||||||
|
|
||||||
|
if let method = entry.method, !method.isEmpty {
|
||||||
|
Divider()
|
||||||
|
detailRow("Method", value: method)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let note = entry.note, !note.isEmpty {
|
||||||
|
Divider()
|
||||||
|
detailRow("Note", value: note)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let loggedAt = entry.loggedAt, !loggedAt.isEmpty {
|
||||||
|
Divider()
|
||||||
|
detailRow("Logged", value: loggedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailRow(_ label: String, value: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatQuantity(_ qty: Double) -> String {
|
||||||
|
if qty == qty.rounded() {
|
||||||
|
return "\(Int(qty))"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", qty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,75 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum FitnessTab: String, CaseIterable {
|
|
||||||
case today = "Today"
|
|
||||||
case templates = "Templates"
|
|
||||||
case goals = "Goals"
|
|
||||||
case foods = "Foods"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FitnessTabView: View {
|
struct FitnessTabView: View {
|
||||||
@State private var selectedTab: FitnessTab = .today
|
@State private var selectedTab: FitnessTab = .today
|
||||||
@State private var showAssistant = false
|
|
||||||
@State private var todayVM = TodayViewModel()
|
@State private var todayVM = TodayViewModel()
|
||||||
|
@State private var showFoodSearch = false
|
||||||
|
|
||||||
|
enum FitnessTab: String, CaseIterable {
|
||||||
|
case today = "Today"
|
||||||
|
case history = "History"
|
||||||
|
case templates = "Templates"
|
||||||
|
case goals = "Goals"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
// Custom segmented control
|
||||||
// Tab bar
|
tabBar
|
||||||
HStack(spacing: 24) {
|
|
||||||
ForEach(FitnessTab.allCases, id: \.self) { tab in
|
// Content
|
||||||
Button {
|
Group {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
switch selectedTab {
|
||||||
selectedTab = tab
|
case .today:
|
||||||
}
|
TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch)
|
||||||
} label: {
|
case .history:
|
||||||
Text(tab.rawValue)
|
HistoryView()
|
||||||
.font(.subheadline)
|
case .templates:
|
||||||
.fontWeight(selectedTab == tab ? .bold : .medium)
|
TemplatesView(dateString: todayVM.dateString) {
|
||||||
.foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary)
|
Task { await todayVM.load() }
|
||||||
.padding(.bottom, 8)
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
if selectedTab == tab {
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.accentWarm)
|
|
||||||
.frame(height: 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
case .goals:
|
||||||
|
GoalsView()
|
||||||
}
|
}
|
||||||
.padding(.top, 60)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
|
|
||||||
// Content
|
|
||||||
TabView(selection: $selectedTab) {
|
|
||||||
TodayView(viewModel: todayVM)
|
|
||||||
.tag(FitnessTab.today)
|
|
||||||
TemplatesView(dateString: todayVM.dateString)
|
|
||||||
.tag(FitnessTab.templates)
|
|
||||||
GoalsView(dateString: todayVM.dateString)
|
|
||||||
.tag(FitnessTab.goals)
|
|
||||||
FoodLibraryView(dateString: todayVM.dateString)
|
|
||||||
.tag(FitnessTab.foods)
|
|
||||||
}
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating + button
|
|
||||||
Button {
|
|
||||||
showAssistant = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.title2.bold())
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
.background(Color.accentWarm)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 20)
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
}
|
||||||
.background(Color.canvas.ignoresSafeArea())
|
.background(Color.canvas)
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.navigationTitle("Fitness")
|
||||||
.sheet(isPresented: $showAssistant) {
|
.navigationBarTitleDisplayMode(.large)
|
||||||
AssistantChatView(entryDate: todayVM.dateString) {
|
.sheet(isPresented: $showFoodSearch) {
|
||||||
|
FoodSearchView(date: todayVM.dateString) {
|
||||||
Task { await todayVM.load() }
|
Task { await todayVM.load() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tabBar: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(FitnessTab.allCases, id: \.rawValue) { tab in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
selectedTab = tab
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(tab.rawValue)
|
||||||
|
.font(.subheadline.weight(selectedTab == tab ? .semibold : .medium))
|
||||||
|
.foregroundStyle(selectedTab == tab ? Color.surface : Color.text3)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
selectedTab == tab
|
||||||
|
? Color.accentWarm
|
||||||
|
: Color.surfaceSecondary
|
||||||
|
)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +1,198 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FoodSearchView: View {
|
struct FoodSearchView: View {
|
||||||
let mealType: MealType
|
let date: String
|
||||||
let dateString: String
|
let onFoodAdded: () -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var vm = FoodSearchViewModel()
|
@State private var viewModel = FoodSearchViewModel()
|
||||||
@State private var selectedFood: FoodItem?
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Search bar
|
// Search bar
|
||||||
HStack {
|
searchBar
|
||||||
Image(systemName: "magnifyingglass")
|
|
||||||
.foregroundStyle(Color.textTertiary)
|
|
||||||
TextField("Search foods...", text: $vm.query)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.onChange(of: vm.query) { _, _ in
|
|
||||||
vm.search()
|
|
||||||
}
|
|
||||||
if !vm.query.isEmpty {
|
|
||||||
Button { vm.query = ""; vm.results = [] } label: {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundStyle(Color.textTertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(Color.surfaceSecondary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
if vm.isSearching || vm.isLoadingInitial {
|
// Content
|
||||||
LoadingView()
|
if viewModel.isSearching || viewModel.isLoadingRecent {
|
||||||
} else if !vm.query.isEmpty && vm.query.count >= 2 {
|
LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...")
|
||||||
// Search results
|
} else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent {
|
||||||
List {
|
EmptyStateView(
|
||||||
Section {
|
icon: "magnifyingglass",
|
||||||
ForEach(vm.results) { food in
|
title: "No results",
|
||||||
foodRow(food)
|
subtitle: "Try a different search term"
|
||||||
}
|
)
|
||||||
} header: {
|
|
||||||
Text("\(vm.results.count) results")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
} else {
|
} else {
|
||||||
// Default: recent + all
|
foodList
|
||||||
List {
|
|
||||||
if !vm.recentFoods.isEmpty {
|
|
||||||
Section("Recent") {
|
|
||||||
ForEach(vm.recentFoods) { food in
|
|
||||||
foodRow(food)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !vm.allFoods.isEmpty {
|
|
||||||
Section("All Foods") {
|
|
||||||
ForEach(vm.allFoods) { food in
|
|
||||||
foodRow(food)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.navigationTitle("Add Food")
|
.navigationTitle("Add Food")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("Cancel") { dismiss() }
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.sheet(isPresented: $viewModel.showAddSheet) {
|
||||||
.task {
|
if let food = viewModel.selectedFood {
|
||||||
await vm.loadInitial()
|
AddFoodSheet(
|
||||||
}
|
food: food,
|
||||||
.sheet(item: $selectedFood) { food in
|
quantity: $viewModel.addQuantity,
|
||||||
AddFoodSheet(food: food, mealType: mealType, dateString: dateString) {
|
mealType: $viewModel.addMealType,
|
||||||
dismiss()
|
isAdding: viewModel.isAddingFood
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
await viewModel.addFood(date: date) {
|
||||||
|
onFoodAdded()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadRecent()
|
||||||
|
searchFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func foodRow(_ food: FoodItem) -> some View {
|
private var searchBar: some View {
|
||||||
Button {
|
HStack(spacing: 10) {
|
||||||
selectedFood = food
|
Image(systemName: "magnifyingglass")
|
||||||
} label: {
|
.foregroundStyle(Color.text4)
|
||||||
HStack {
|
|
||||||
if let img = food.imageFilename {
|
TextField("Search foods...", text: $viewModel.searchText)
|
||||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
.textFieldStyle(.plain)
|
||||||
image.resizable().aspectRatio(contentMode: .fill)
|
.autocorrectionDisabled()
|
||||||
} placeholder: {
|
.textInputAutocapitalization(.never)
|
||||||
Color.surfaceSecondary
|
.focused($searchFocused)
|
||||||
}
|
.onSubmit {
|
||||||
.frame(width: 40, height: 40)
|
viewModel.search()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
}
|
||||||
|
.onChange(of: viewModel.searchText) {
|
||||||
|
viewModel.search()
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
if !viewModel.searchText.isEmpty {
|
||||||
Text(food.name)
|
Button {
|
||||||
.font(.subheadline)
|
viewModel.searchText = ""
|
||||||
.foregroundStyle(Color.textPrimary)
|
viewModel.searchResults = []
|
||||||
Text("\(Int(food.calories)) cal")
|
} label: {
|
||||||
.font(.caption)
|
Image(systemName: "xmark.circle.fill")
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.text4)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
Image(systemName: "plus.circle")
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foodList: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty {
|
||||||
|
sectionHeader("Recent Foods")
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.displayedFoods) { food in
|
||||||
|
FoodItemRow(food: food) {
|
||||||
|
viewModel.selectFood(food)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FoodItemRow: View {
|
||||||
|
let food: FoodItem
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Icon
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.accentWarmBg)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
if let imageUrl = food.imageUrl, !imageUrl.isEmpty {
|
||||||
|
AsyncImage(url: URL(string: imageUrl)) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
} placeholder: {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(food.name)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(food.displayInfo)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Calories
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(Int(food.caloriesPerBase))")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
|
||||||
|
Text("kcal")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,139 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GoalsView: View {
|
struct GoalsView: View {
|
||||||
let dateString: String
|
@State private var viewModel = GoalsViewModel()
|
||||||
@State private var vm = GoalsViewModel()
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
if viewModel.isLoading {
|
||||||
if vm.isLoading {
|
LoadingView(message: "Loading goals...")
|
||||||
LoadingView()
|
.frame(height: 300)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 20) {
|
||||||
goalField("Calories", value: $vm.calories, unit: "kcal")
|
// Header
|
||||||
goalField("Protein", value: $vm.protein, unit: "g")
|
Text("Your daily targets")
|
||||||
goalField("Carbs", value: $vm.carbs, unit: "g")
|
.font(.headline)
|
||||||
goalField("Fat", value: $vm.fat, unit: "g")
|
.foregroundStyle(Color.text1)
|
||||||
goalField("Sugar", value: $vm.sugar, unit: "g")
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
goalField("Fiber", value: $vm.fiber, unit: "g")
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
|
|
||||||
Button {
|
// Goals cards
|
||||||
Task { await vm.save() }
|
goalCard(
|
||||||
} label: {
|
label: "Calories",
|
||||||
Group {
|
value: viewModel.goal.calories,
|
||||||
if vm.isSaving {
|
unit: "kcal",
|
||||||
ProgressView().tint(.white)
|
icon: "flame.fill",
|
||||||
} else {
|
color: .caloriesColor
|
||||||
Text("Save Goals")
|
)
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 48)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(Color.accentWarm)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.disabled(vm.isSaving)
|
|
||||||
|
|
||||||
if vm.saved {
|
goalCard(
|
||||||
Text("Goals saved!")
|
label: "Protein",
|
||||||
|
value: viewModel.goal.protein,
|
||||||
|
unit: "g",
|
||||||
|
icon: "circle.hexagonpath.fill",
|
||||||
|
color: .proteinColor
|
||||||
|
)
|
||||||
|
|
||||||
|
goalCard(
|
||||||
|
label: "Carbs",
|
||||||
|
value: viewModel.goal.carbs,
|
||||||
|
unit: "g",
|
||||||
|
icon: "bolt.fill",
|
||||||
|
color: .carbsColor
|
||||||
|
)
|
||||||
|
|
||||||
|
goalCard(
|
||||||
|
label: "Fat",
|
||||||
|
value: viewModel.goal.fat,
|
||||||
|
unit: "g",
|
||||||
|
icon: "drop.fill",
|
||||||
|
color: .fatColor
|
||||||
|
)
|
||||||
|
|
||||||
|
if let sugar = viewModel.goal.sugar, sugar > 0 {
|
||||||
|
goalCard(
|
||||||
|
label: "Sugar",
|
||||||
|
value: sugar,
|
||||||
|
unit: "g",
|
||||||
|
icon: "cube.fill",
|
||||||
|
color: .sugarColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fiber = viewModel.goal.fiber, fiber > 0 {
|
||||||
|
goalCard(
|
||||||
|
label: "Fiber",
|
||||||
|
value: fiber,
|
||||||
|
unit: "g",
|
||||||
|
icon: "leaf.fill",
|
||||||
|
color: .fiberColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info note
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
Text("Goals can be updated from the web app")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.emerald)
|
.foregroundStyle(Color.text3)
|
||||||
}
|
|
||||||
|
|
||||||
if let err = vm.error {
|
|
||||||
ErrorBanner(message: err)
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
Spacer(minLength: 80)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await vm.load(date: dateString)
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func goalField(_ label: String, value: Binding<String>, unit: String) -> some View {
|
private func goalCard(label: String, value: Double, unit: String, icon: String, color: Color) -> some View {
|
||||||
HStack {
|
HStack(spacing: 16) {
|
||||||
Text(label)
|
ZStack {
|
||||||
.font(.subheadline)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.foregroundStyle(Color.textSecondary)
|
.fill(color.opacity(0.1))
|
||||||
.frame(width: 80, alignment: .leading)
|
.frame(width: 48, height: 48)
|
||||||
TextField("0", text: value)
|
|
||||||
.keyboardType(.numberPad)
|
Image(systemName: icon)
|
||||||
.textFieldStyle(.plain)
|
.font(.title3)
|
||||||
.font(.subheadline.bold())
|
.foregroundStyle(color)
|
||||||
.padding(10)
|
}
|
||||||
.background(Color.surfaceSecondary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(unit)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.textTertiary)
|
.foregroundStyle(Color.text2)
|
||||||
.frame(width: 32)
|
|
||||||
|
Text("Daily target")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text(unit)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
159
ios/Platform/Platform/Features/Fitness/Views/HistoryView.swift
Normal file
159
ios/Platform/Platform/Features/Fitness/Views/HistoryView.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HistoryView: View {
|
||||||
|
@State private var viewModel = HistoryViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
LoadingView(message: "Loading history...")
|
||||||
|
.frame(height: 300)
|
||||||
|
} else if viewModel.days.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "calendar",
|
||||||
|
title: "No history",
|
||||||
|
subtitle: "Start logging food to see your history"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
ForEach(viewModel.days) { day in
|
||||||
|
HistoryDayCard(day: day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistoryDayCard: View {
|
||||||
|
let day: HistoryViewModel.HistoryDay
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Date
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(day.date.relativeLabel)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text(day.date.shortDisplayString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Quick stats
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(Int(day.totalCalories))")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text("kcal")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini progress ring
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.caloriesColor.opacity(0.12), lineWidth: 3)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: day.calorieProgress)
|
||||||
|
.stroke(Color.caloriesColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
}
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Macros row
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
historyMacro("Protein", value: day.totalProtein, color: .proteinColor)
|
||||||
|
Spacer()
|
||||||
|
historyMacro("Carbs", value: day.totalCarbs, color: .carbsColor)
|
||||||
|
Spacer()
|
||||||
|
historyMacro("Fat", value: day.totalFat, color: .fatColor)
|
||||||
|
Spacer()
|
||||||
|
historyMacro("Entries", value: Double(day.entryCount), color: .text3, isCount: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
if !day.entries.isEmpty {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Entries list
|
||||||
|
ForEach(day.entries) { entry in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.mealColor(for: entry.mealType))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
|
||||||
|
Text(entry.foodName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(Int(entry.calories)) kcal")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func historyMacro(_ label: String, value: Double, color: Color, isCount: Bool = false) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(Int(value))\(isCount ? "" : "g")")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,131 +1,261 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MealSectionView: View {
|
struct MealSectionView: View {
|
||||||
let meal: MealType
|
let group: MealGroup
|
||||||
let entries: [FoodEntry]
|
let isExpanded: Bool
|
||||||
let mealCalories: Double
|
let onToggle: () -> Void
|
||||||
let onDelete: (FoodEntry) -> Void
|
let onDelete: (FoodEntry) -> Void
|
||||||
let dateString: String
|
let onAddFood: () -> Void
|
||||||
|
|
||||||
@State private var isExpanded = true
|
|
||||||
@State private var showFoodSearch = false
|
|
||||||
@State private var selectedEntry: FoodEntry?
|
@State private var selectedEntry: FoodEntry?
|
||||||
|
|
||||||
|
private var mealColor: Color {
|
||||||
|
Color.mealColor(for: group.meal.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Header
|
||||||
Button {
|
Button(action: onToggle) {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
HStack(spacing: 10) {
|
||||||
isExpanded.toggle()
|
Image(systemName: group.meal.icon)
|
||||||
}
|
.font(.body)
|
||||||
} label: {
|
.foregroundStyle(mealColor)
|
||||||
HStack(spacing: 0) {
|
|
||||||
// Colored accent bar
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(meal.color)
|
|
||||||
.frame(width: 4, height: 44)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
|
|
||||||
Image(systemName: meal.icon)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(meal.color)
|
|
||||||
.frame(width: 28)
|
.frame(width: 28)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text(group.meal.displayName)
|
||||||
Text(meal.displayName)
|
.font(.subheadline.weight(.semibold))
|
||||||
.font(.headline)
|
.foregroundStyle(Color.text1)
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
Text("\(entries.count) item\(entries.count == 1 ? "" : "s")")
|
if !group.entries.isEmpty {
|
||||||
.font(.caption)
|
Text("\(group.entries.count)")
|
||||||
.foregroundStyle(Color.textSecondary)
|
.font(.caption2.weight(.bold))
|
||||||
|
.foregroundStyle(mealColor)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(mealColor.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
.padding(.leading, 8)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text("\(Int(mealCalories)) cal")
|
if !group.entries.isEmpty {
|
||||||
.font(.subheadline.bold())
|
Text("\(Int(group.totalCalories)) kcal")
|
||||||
.foregroundStyle(meal.color)
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
.font(.caption2)
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(Color.textTertiary)
|
.foregroundStyle(Color.text4)
|
||||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
||||||
.padding(.leading, 8)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// Entries
|
// Entries
|
||||||
if isExpanded && !entries.isEmpty {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ForEach(entries) { entry in
|
|
||||||
Button {
|
|
||||||
selectedEntry = entry
|
|
||||||
} label: {
|
|
||||||
entryRow(entry)
|
|
||||||
}
|
|
||||||
.swipeActions(edge: .trailing) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
onDelete(entry)
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.leading, 40)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add button
|
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
Button {
|
if group.entries.isEmpty {
|
||||||
showFoodSearch = true
|
emptyMealView
|
||||||
} label: {
|
} else {
|
||||||
HStack {
|
Divider()
|
||||||
Image(systemName: "plus.circle.fill")
|
.padding(.horizontal, 16)
|
||||||
.foregroundStyle(meal.color.opacity(0.6))
|
|
||||||
Text("Add food")
|
ForEach(group.entries) { entry in
|
||||||
.font(.subheadline)
|
SwipeToDeleteRow(onDelete: { onDelete(entry) }) {
|
||||||
.foregroundStyle(Color.textSecondary)
|
EntryRow(entry: entry)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedEntry = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.id != group.entries.last?.id {
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 52)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
.padding(.leading, 44)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(meal.color.opacity(0.03))
|
.background(Color.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.padding(.horizontal, 16)
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
.sheet(isPresented: $showFoodSearch) {
|
|
||||||
FoodSearchView(mealType: meal, dateString: dateString)
|
|
||||||
}
|
|
||||||
.sheet(item: $selectedEntry) { entry in
|
.sheet(item: $selectedEntry) { entry in
|
||||||
EntryDetailView(entry: entry, dateString: dateString)
|
EntryDetailView(
|
||||||
|
entry: entry,
|
||||||
|
onDelete: { onDelete(entry) },
|
||||||
|
onUpdateQuantity: { _ in }
|
||||||
|
)
|
||||||
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func entryRow(_ entry: FoodEntry) -> some View {
|
private var emptyMealView: some View {
|
||||||
HStack {
|
Button(action: onAddFood) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack(spacing: 8) {
|
||||||
Text(entry.foodName)
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(mealColor)
|
||||||
|
Text("Add food")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.text3)
|
||||||
.lineLimit(1)
|
|
||||||
if entry.quantity != 1 {
|
|
||||||
Text("x\(String(format: "%.1f", entry.quantity))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer()
|
.frame(maxWidth: .infinity)
|
||||||
Text("\(Int(entry.calories * entry.quantity))")
|
.padding(.vertical, 16)
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 16)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Swipe to Delete Row
|
||||||
|
|
||||||
|
struct SwipeToDeleteRow<Content: View>: View {
|
||||||
|
let onDelete: () -> Void
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
@State private var offset: CGFloat = 0
|
||||||
|
@State private var showDelete = false
|
||||||
|
|
||||||
|
private let deleteThreshold: CGFloat = -80
|
||||||
|
private let deleteWidth: CGFloat = 80
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .trailing) {
|
||||||
|
// Delete background
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
offset = -300
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
onDelete()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "trash.fill")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: deleteWidth, height: .infinity)
|
||||||
|
}
|
||||||
|
.frame(width: deleteWidth)
|
||||||
|
.background(Color.error)
|
||||||
|
}
|
||||||
|
.opacity(offset < 0 ? 1 : 0)
|
||||||
|
|
||||||
|
// Content
|
||||||
|
content()
|
||||||
|
.offset(x: offset)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 20)
|
||||||
|
.onChanged { value in
|
||||||
|
let translation = value.translation.width
|
||||||
|
if translation < 0 {
|
||||||
|
offset = translation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
if offset < deleteThreshold {
|
||||||
|
offset = -deleteWidth
|
||||||
|
showDelete = true
|
||||||
|
} else {
|
||||||
|
offset = 0
|
||||||
|
showDelete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
if showDelete {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
offset = 0
|
||||||
|
showDelete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry Row
|
||||||
|
|
||||||
|
struct EntryRow: View {
|
||||||
|
let entry: FoodEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Food icon or image
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
if let imageUrl = entry.imageUrl, !imageUrl.isEmpty {
|
||||||
|
AsyncImage(url: URL(string: imageUrl)) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} placeholder: {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name and serving
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.foodName)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Macros
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(Int(entry.calories))")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
+ Text(" kcal")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
macroTag("P", value: entry.protein, color: .proteinColor)
|
||||||
|
macroTag("C", value: entry.carbs, color: .carbsColor)
|
||||||
|
macroTag("F", value: entry.fat, color: .fatColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroTag(_ label: String, value: Double, color: Color) -> some View {
|
||||||
|
Text("\(label)\(Int(value))")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatQuantity(_ qty: Double) -> String {
|
||||||
|
if qty == qty.rounded() {
|
||||||
|
return "\(Int(qty))"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", qty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,102 +2,169 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TemplatesView: View {
|
struct TemplatesView: View {
|
||||||
let dateString: String
|
let dateString: String
|
||||||
@State private var vm = TemplatesViewModel()
|
let onTemplateLogged: () -> Void
|
||||||
@State private var templateToLog: MealTemplate?
|
|
||||||
@State private var showConfirm = false
|
@State private var viewModel = TemplatesViewModel()
|
||||||
|
@State private var confirmTemplate: MealTemplate?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
if viewModel.isLoading {
|
||||||
if vm.isLoading {
|
LoadingView(message: "Loading templates...")
|
||||||
LoadingView()
|
.frame(height: 300)
|
||||||
} else if vm.templates.isEmpty {
|
} else if viewModel.templates.isEmpty {
|
||||||
EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app")
|
EmptyStateView(
|
||||||
} else {
|
icon: "doc.text",
|
||||||
|
title: "No templates",
|
||||||
|
subtitle: "Create templates on the web app to quickly log meals"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyVStack(spacing: 16) {
|
||||||
ForEach(MealType.allCases) { meal in
|
ForEach(MealType.allCases) { meal in
|
||||||
let templates = vm.groupedByMeal[meal] ?? []
|
let templates = viewModel.groupedTemplates[meal.rawValue] ?? []
|
||||||
if !templates.isEmpty {
|
if !templates.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
templateSection(meal: meal, templates: templates)
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: meal.icon)
|
|
||||||
.foregroundStyle(meal.color)
|
|
||||||
Text(meal.displayName)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
|
|
||||||
ForEach(templates) { template in
|
|
||||||
templateCard(template)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let msg = vm.logSuccess {
|
// Ungrouped
|
||||||
Text(msg)
|
let ungrouped = viewModel.templates.filter { template in
|
||||||
.font(.caption)
|
!MealType.allCases.map(\.rawValue).contains(template.mealType)
|
||||||
.foregroundStyle(Color.emerald)
|
}
|
||||||
.padding(.top, 4)
|
if !ungrouped.isEmpty {
|
||||||
|
templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
if let err = vm.error {
|
|
||||||
ErrorBanner(message: err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 80)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await vm.load()
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
.alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in
|
.confirmationDialog(
|
||||||
Button("Log") {
|
"Log Template",
|
||||||
Task { await vm.logTemplate(template, date: dateString) }
|
isPresented: Binding(
|
||||||
|
get: { confirmTemplate != nil },
|
||||||
|
set: { if !$0 { confirmTemplate = nil } }
|
||||||
|
),
|
||||||
|
presenting: confirmTemplate
|
||||||
|
) { template in
|
||||||
|
Button("Log \"\(template.name)\"") {
|
||||||
|
Task {
|
||||||
|
await viewModel.logTemplate(template, date: dateString) {
|
||||||
|
onTemplateLogged()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: { template in
|
} message: { template in
|
||||||
Text("Add \(template.name) to today's log?")
|
Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func templateCard(_ template: MealTemplate) -> some View {
|
private func templateSection(meal: MealType, templates: [MealTemplate]) -> some View {
|
||||||
HStack {
|
templateSection(
|
||||||
|
mealLabel: meal.displayName,
|
||||||
|
icon: meal.icon,
|
||||||
|
color: Color.mealColor(for: meal.rawValue),
|
||||||
|
templates: templates
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func templateSection(mealLabel: String, icon: String, color: Color, templates: [MealTemplate]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(mealLabel)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(templates) { template in
|
||||||
|
TemplateCard(
|
||||||
|
template: template,
|
||||||
|
isLogging: viewModel.loggedTemplateId == template.id
|
||||||
|
) {
|
||||||
|
confirmTemplate = template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateCard: View {
|
||||||
|
let template: MealTemplate
|
||||||
|
let isLogging: Bool
|
||||||
|
let onLog: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
// Icon
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.mealColor(for: template.mealType).opacity(0.1))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: "doc.text.fill")
|
||||||
|
.foregroundStyle(Color.mealColor(for: template.mealType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(template.name)
|
Text(template.name)
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.text1)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let cal = template.totalCalories {
|
Text("\(Int(template.calories)) kcal")
|
||||||
Text("\(Int(cal)) cal")
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.caloriesColor)
|
||||||
|
|
||||||
|
if let count = template.itemsCount {
|
||||||
|
Text("\(count) items")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
}
|
}
|
||||||
if let items = template.itemCount {
|
|
||||||
Text("\(items) items")
|
if let protein = template.protein {
|
||||||
|
Text("P\(Int(protein))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.proteinColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
|
||||||
templateToLog = template
|
// Log button
|
||||||
showConfirm = true
|
Button(action: onLog) {
|
||||||
} label: {
|
if isLogging {
|
||||||
Text("Log meal")
|
ProgressView()
|
||||||
.font(.caption.bold())
|
.controlSize(.small)
|
||||||
.foregroundStyle(.white)
|
.tint(Color.accentWarm)
|
||||||
.padding(.horizontal, 12)
|
} else {
|
||||||
.padding(.vertical, 6)
|
Image(systemName: "plus.circle.fill")
|
||||||
.background(Color.accentWarm)
|
.font(.title3)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(isLogging)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background(Color.surface)
|
.background(Color.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 1)
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,95 +2,170 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TodayView: View {
|
struct TodayView: View {
|
||||||
@Bindable var viewModel: TodayViewModel
|
@Bindable var viewModel: TodayViewModel
|
||||||
@State private var showFoodSearch = false
|
@Binding var showFoodSearch: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
VStack(spacing: 16) {
|
ScrollView {
|
||||||
// Date selector
|
VStack(spacing: 16) {
|
||||||
HStack {
|
// Date selector
|
||||||
Button { viewModel.previousDay() } label: {
|
dateSelector
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.headline)
|
if viewModel.isLoading {
|
||||||
.foregroundStyle(Color.accentWarm)
|
LoadingView(message: "Loading entries...")
|
||||||
}
|
.frame(height: 200)
|
||||||
Spacer()
|
} else {
|
||||||
Text(viewModel.displayDate)
|
// Macro summary card
|
||||||
.font(.headline)
|
macroSummaryCard
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
Spacer()
|
// Meal sections
|
||||||
Button { viewModel.nextDay() } label: {
|
ForEach(viewModel.mealGroups) { group in
|
||||||
Image(systemName: "chevron.right")
|
MealSectionView(
|
||||||
.font(.headline)
|
group: group,
|
||||||
.foregroundStyle(Color.accentWarm)
|
isExpanded: viewModel.expandedMeals.contains(group.meal.rawValue),
|
||||||
}
|
onToggle: { viewModel.toggleMeal(group.meal.rawValue) },
|
||||||
}
|
onDelete: { entry in
|
||||||
.padding(.horizontal, 20)
|
Task { await viewModel.deleteEntry(entry) }
|
||||||
.padding(.top, 8)
|
},
|
||||||
.gesture(
|
onAddFood: {
|
||||||
DragGesture(minimumDistance: 50)
|
showFoodSearch = true
|
||||||
.onEnded { value in
|
}
|
||||||
if value.translation.width > 0 {
|
)
|
||||||
viewModel.previousDay()
|
|
||||||
} else {
|
|
||||||
viewModel.nextDay()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// Macro summary card
|
// Bottom spacing for FAB
|
||||||
if !viewModel.repository.isLoading {
|
Spacer()
|
||||||
macroSummaryCard
|
.frame(height: 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
ErrorBanner(message: error) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
// Error
|
|
||||||
if let error = viewModel.repository.error {
|
|
||||||
ErrorBanner(message: error) { await viewModel.load() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meal sections
|
|
||||||
ForEach(MealType.allCases) { meal in
|
|
||||||
MealSectionView(
|
|
||||||
meal: meal,
|
|
||||||
entries: viewModel.repository.entriesForMeal(meal),
|
|
||||||
mealCalories: viewModel.repository.mealCalories(meal),
|
|
||||||
onDelete: { entry in
|
|
||||||
Task { await viewModel.deleteEntry(entry) }
|
|
||||||
},
|
|
||||||
dateString: viewModel.dateString
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 80)
|
|
||||||
}
|
}
|
||||||
}
|
.refreshable {
|
||||||
.refreshable {
|
await viewModel.load()
|
||||||
await viewModel.load()
|
}
|
||||||
|
|
||||||
|
// Floating add button
|
||||||
|
addButton
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var macroSummaryCard: some View {
|
// MARK: - Date Selector
|
||||||
let repo = viewModel.repository
|
|
||||||
let goals = repo.goals
|
|
||||||
|
|
||||||
return VStack(spacing: 12) {
|
private var dateSelector: some View {
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 0) {
|
||||||
LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories)
|
Button {
|
||||||
|
viewModel.goToPreviousDay()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
Spacer()
|
||||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue)
|
|
||||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange)
|
VStack(spacing: 2) {
|
||||||
MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple)
|
Text(viewModel.selectedDate.relativeLabel)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
if !viewModel.selectedDate.isToday {
|
||||||
|
Text(viewModel.selectedDate.displayString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.goToNextDay()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.foregroundStyle(
|
||||||
|
viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm
|
||||||
|
)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.selectedDate.isToday)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Macro Summary
|
||||||
|
|
||||||
|
private var macroSummaryCard: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Calories ring
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
MacroRingLarge(
|
||||||
|
current: viewModel.totalCalories,
|
||||||
|
goal: viewModel.goal.calories,
|
||||||
|
color: .caloriesColor,
|
||||||
|
size: 100,
|
||||||
|
lineWidth: 9
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
macroRow("Protein", current: viewModel.totalProtein, goal: viewModel.goal.protein, color: .proteinColor)
|
||||||
|
macroRow("Carbs", current: viewModel.totalCarbs, goal: viewModel.goal.carbs, color: .carbsColor)
|
||||||
|
macroRow("Fat", current: viewModel.totalFat, goal: viewModel.goal.fat, color: .fatColor)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
.background(Color.surface)
|
.background(Color.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
.padding(.horizontal, 16)
|
}
|
||||||
|
|
||||||
|
private func macroRow(_ label: String, current: Double, goal: Double, color: Color) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(current))/\(Int(goal))g")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
}
|
||||||
|
MacroBarCompact(current: current, goal: goal, color: color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Add Button
|
||||||
|
|
||||||
|
private var addButton: some View {
|
||||||
|
Button {
|
||||||
|
showFoodSearch = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +1,188 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(AuthManager.self) private var authManager
|
@Environment(AuthManager.self) private var authManager
|
||||||
@State private var vm = HomeViewModel()
|
@State private var viewModel = HomeViewModel()
|
||||||
@State private var showAssistant = false
|
|
||||||
@State private var showProfileMenu = false
|
|
||||||
@State private var selectedPhoto: PhotosPickerItem?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ScrollView {
|
||||||
// Background
|
VStack(spacing: 20) {
|
||||||
if let bg = vm.backgroundImage {
|
if viewModel.isLoading {
|
||||||
Image(uiImage: bg)
|
LoadingView(message: "Loading dashboard...")
|
||||||
.resizable()
|
.frame(height: 300)
|
||||||
.aspectRatio(contentMode: .fill)
|
} else {
|
||||||
.ignoresSafeArea()
|
// Quick Stats Card
|
||||||
.overlay(Color.black.opacity(0.2).ignoresSafeArea())
|
caloriesSummaryCard
|
||||||
}
|
|
||||||
else {
|
|
||||||
Color.canvas.ignoresSafeArea()
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollView {
|
// Macros Card
|
||||||
VStack(spacing: 16) {
|
macrosCard
|
||||||
// Top bar
|
|
||||||
HStack {
|
// Quick Actions
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
quickActionsCard
|
||||||
Text("Dashboard")
|
}
|
||||||
.font(.title2.bold())
|
|
||||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary)
|
if let error = viewModel.errorMessage {
|
||||||
if let name = authManager.user?.displayName ?? authManager.user?.username {
|
ErrorBanner(message: error) {
|
||||||
Text("Welcome, \(name)")
|
Task { await viewModel.load() }
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(vm.backgroundImage != nil ? .white.opacity(0.8) : Color.textSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Menu {
|
|
||||||
PhotosPicker(selection: $selectedPhoto, matching: .images) {
|
|
||||||
Label("Change Background", systemImage: "photo")
|
|
||||||
}
|
|
||||||
if vm.backgroundImage != nil {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
vm.removeBackground()
|
|
||||||
} label: {
|
|
||||||
Label("Remove Background", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
Button(role: .destructive) {
|
|
||||||
authManager.logout()
|
|
||||||
} label: {
|
|
||||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "person.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accentWarm)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.top, 60)
|
|
||||||
|
|
||||||
// Widget grid
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
|
||||||
calorieWidget
|
|
||||||
quickStatsWidget
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
|
|
||||||
Spacer(minLength: 100)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
// Floating + button
|
|
||||||
Button {
|
|
||||||
showAssistant = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.title2.bold())
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
.background(Color.accentWarm)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 20)
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
}
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.background(Color.canvas)
|
||||||
.sheet(isPresented: $showAssistant) {
|
.navigationTitle("Dashboard")
|
||||||
AssistantChatView(entryDate: Date().apiDateString) {
|
.toolbar {
|
||||||
Task { await vm.loadData() }
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
}
|
Menu {
|
||||||
}
|
Button(role: .destructive) {
|
||||||
.onChange(of: selectedPhoto) { _, newValue in
|
authManager.logout()
|
||||||
guard let item = newValue else { return }
|
} label: {
|
||||||
Task {
|
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
if let data = try? await item.loadTransferable(type: Data.self),
|
}
|
||||||
let image = UIImage(data: data) {
|
} label: {
|
||||||
vm.setBackground(image)
|
Image(systemName: "person.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
selectedPhoto = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await vm.loadData()
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var calorieWidget: some View {
|
private var caloriesSummaryCard: some View {
|
||||||
let hasBg = vm.backgroundImage != nil
|
VStack(spacing: 16) {
|
||||||
return VStack(spacing: 8) {
|
HStack {
|
||||||
LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Calories")
|
Text("Today")
|
||||||
.font(.caption.bold())
|
.font(.headline)
|
||||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
.foregroundStyle(Color.text1)
|
||||||
}
|
Text(Date().displayString)
|
||||||
.padding(16)
|
.font(.subheadline)
|
||||||
.frame(maxWidth: .infinity)
|
.foregroundStyle(Color.text3)
|
||||||
.background {
|
}
|
||||||
if hasBg {
|
Spacer()
|
||||||
RoundedRectangle(cornerRadius: 16)
|
Text("\(viewModel.entryCount) entries")
|
||||||
.fill(.ultraThinMaterial)
|
.font(.caption)
|
||||||
} else {
|
.foregroundStyle(Color.text4)
|
||||||
RoundedRectangle(cornerRadius: 16)
|
.padding(.horizontal, 10)
|
||||||
.fill(Color.surface)
|
.padding(.vertical, 4)
|
||||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
.background(Color.surfaceSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
MacroRingLarge(
|
||||||
|
current: viewModel.totalCalories,
|
||||||
|
goal: viewModel.goal.calories,
|
||||||
|
color: .caloriesColor,
|
||||||
|
size: 140,
|
||||||
|
lineWidth: 12
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal")
|
||||||
|
Spacer()
|
||||||
|
macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal")
|
||||||
|
Spacer()
|
||||||
|
macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var quickStatsWidget: some View {
|
private var macrosCard: some View {
|
||||||
let hasBg = vm.backgroundImage != nil
|
VStack(spacing: 14) {
|
||||||
let repo = FitnessRepository.shared
|
|
||||||
return VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text("Macros")
|
Text("Macros")
|
||||||
.font(.caption.bold())
|
.font(.headline)
|
||||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
.foregroundStyle(Color.text1)
|
||||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: repo.goals.carbs, color: .orange, compact: true)
|
|
||||||
MacroBar(label: "Fat", value: repo.totalFat, goal: repo.goals.fat, color: .purple, compact: true)
|
HStack(spacing: 20) {
|
||||||
|
MacroRing(
|
||||||
|
current: viewModel.totalProtein,
|
||||||
|
goal: viewModel.goal.protein,
|
||||||
|
color: .proteinColor,
|
||||||
|
label: "Protein",
|
||||||
|
unit: "g",
|
||||||
|
size: 68
|
||||||
|
)
|
||||||
|
MacroRing(
|
||||||
|
current: viewModel.totalCarbs,
|
||||||
|
goal: viewModel.goal.carbs,
|
||||||
|
color: .carbsColor,
|
||||||
|
label: "Carbs",
|
||||||
|
unit: "g",
|
||||||
|
size: 68
|
||||||
|
)
|
||||||
|
MacroRing(
|
||||||
|
current: viewModel.totalFat,
|
||||||
|
goal: viewModel.goal.fat,
|
||||||
|
color: .fatColor,
|
||||||
|
label: "Fat",
|
||||||
|
unit: "g",
|
||||||
|
size: 68
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(20)
|
||||||
.frame(maxWidth: .infinity)
|
.background(Color.surface)
|
||||||
.background {
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
if hasBg {
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
RoundedRectangle(cornerRadius: 16)
|
}
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
} else {
|
private var quickActionsCard: some View {
|
||||||
RoundedRectangle(cornerRadius: 16)
|
VStack(spacing: 12) {
|
||||||
.fill(Color.surface)
|
Text("Quick Actions")
|
||||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald)
|
||||||
|
quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor)
|
||||||
|
quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func macroStat(_ label: String, value: Int, unit: String) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(value)")
|
||||||
|
.font(.system(.title3, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text("\(label)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func quickActionButton(icon: String, label: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Color.text2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(color.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,49 @@
|
|||||||
import SwiftUI
|
import Foundation
|
||||||
import PhotosUI
|
|
||||||
|
|
||||||
@Observable
|
@MainActor @Observable
|
||||||
final class HomeViewModel {
|
final class HomeViewModel {
|
||||||
var backgroundImage: UIImage?
|
var todayEntries: [FoodEntry] = []
|
||||||
var caloriesConsumed: Double = 0
|
var goal: DailyGoal = .defaultGoal
|
||||||
var caloriesGoal: Double = 2000
|
var isLoading = true
|
||||||
var isLoading = false
|
var errorMessage: String?
|
||||||
|
|
||||||
private let bgKey = "homeBackgroundImage"
|
|
||||||
private let repo = FitnessRepository.shared
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
init() {
|
var totalCalories: Double {
|
||||||
loadBackgroundFromDefaults()
|
todayEntries.reduce(0) { $0 + $1.calories }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadData() async {
|
var totalProtein: Double {
|
||||||
|
todayEntries.reduce(0) { $0 + $1.protein }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCarbs: Double {
|
||||||
|
todayEntries.reduce(0) { $0 + $1.carbs }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFat: Double {
|
||||||
|
todayEntries.reduce(0) { $0 + $1.fat }
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryCount: Int {
|
||||||
|
todayEntries.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
let today = Date().apiDateString
|
let today = Date().apiDateString
|
||||||
await repo.loadDay(date: today)
|
|
||||||
caloriesConsumed = repo.totalCalories
|
do {
|
||||||
caloriesGoal = repo.goals.calories
|
async let entriesTask = repo.entries(for: today, forceRefresh: true)
|
||||||
|
async let goalsTask = repo.goals(for: today)
|
||||||
|
|
||||||
|
todayEntries = try await entriesTask
|
||||||
|
goal = try await goalsTask
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func setBackground(_ image: UIImage) {
|
|
||||||
// Resize to max 1200px
|
|
||||||
let maxDim: CGFloat = 1200
|
|
||||||
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)
|
|
||||||
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
|
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
|
||||||
let resized = UIGraphicsGetImageFromCurrentImageContext()
|
|
||||||
UIGraphicsEndImageContext()
|
|
||||||
|
|
||||||
if let resized, let data = resized.jpegData(compressionQuality: 0.8) {
|
|
||||||
UserDefaults.standard.set(data, forKey: bgKey)
|
|
||||||
backgroundImage = resized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeBackground() {
|
|
||||||
UserDefaults.standard.removeObject(forKey: bgKey)
|
|
||||||
backgroundImage = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadBackgroundFromDefaults() {
|
|
||||||
if let data = UserDefaults.standard.data(forKey: bgKey),
|
|
||||||
let img = UIImage(data: data) {
|
|
||||||
backgroundImage = img
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,41 +4,45 @@ struct LoadingView: View {
|
|||||||
var message: String = "Loading..."
|
var message: String = "Loading..."
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 16) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(.accentWarm)
|
.controlSize(.large)
|
||||||
|
.tint(Color.accentWarm)
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.text3)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.canvas)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ErrorBanner: View {
|
struct ErrorBanner: View {
|
||||||
let message: String
|
let message: String
|
||||||
var retry: (() async -> Void)?
|
var onRetry: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(Color.error)
|
||||||
|
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.text2)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
if let retry = retry {
|
|
||||||
|
if let onRetry {
|
||||||
Button("Retry") {
|
Button("Retry") {
|
||||||
Task { await retry() }
|
onRetry()
|
||||||
}
|
}
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Color.orange.opacity(0.1))
|
.background(Color.error.opacity(0.06))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +55,15 @@ struct EmptyStateView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundStyle(Color.textTertiary)
|
.foregroundStyle(Color.text4)
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.text2)
|
||||||
|
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.text3)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.padding(40)
|
.padding(40)
|
||||||
|
|||||||
@@ -2,43 +2,74 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MacroBar: View {
|
struct MacroBar: View {
|
||||||
let label: String
|
let label: String
|
||||||
let value: Double
|
let current: Double
|
||||||
let goal: Double
|
let goal: Double
|
||||||
let color: Color
|
let color: Color
|
||||||
var compact: Bool = false
|
var showGrams: Bool = true
|
||||||
|
|
||||||
private var progress: Double {
|
private var progress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(max(value / goal, 0), 1)
|
return min(current / goal, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(compact ? .caption2 : .caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(value))/\(Int(goal))g")
|
if showGrams {
|
||||||
.font(compact ? .caption2 : .caption)
|
Text("\(Int(current))g / \(Int(goal))g")
|
||||||
.fontWeight(.semibold)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.text3)
|
||||||
|
} else {
|
||||||
|
Text("\(Int(current)) / \(Int(goal))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
Capsule()
|
||||||
.fill(color.opacity(0.15))
|
.fill(color.opacity(0.12))
|
||||||
.frame(height: compact ? 4 : 6)
|
.frame(height: 6)
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
Capsule()
|
||||||
.fill(color)
|
.fill(color)
|
||||||
.frame(width: max(0, geo.size.width * progress), height: compact ? 4 : 6)
|
.frame(width: geo.size.width * progress, height: 6)
|
||||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: compact ? 4 : 6)
|
.frame(height: 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MacroBarCompact: View {
|
||||||
|
let current: Double
|
||||||
|
let goal: Double
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
private var progress: Double {
|
||||||
|
guard goal > 0 else { return 0 }
|
||||||
|
return min(current / goal, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(color.opacity(0.12))
|
||||||
|
|
||||||
|
Capsule()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: geo.size.width * progress)
|
||||||
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,83 +1,96 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MacroRing: View {
|
struct MacroRing: View {
|
||||||
let consumed: Double
|
let current: Double
|
||||||
let goal: Double
|
let goal: Double
|
||||||
let color: Color
|
let color: Color
|
||||||
var size: CGFloat = 80
|
let label: String
|
||||||
var lineWidth: CGFloat = 8
|
let unit: String
|
||||||
var showLabel: Bool = true
|
var size: CGFloat = 72
|
||||||
var labelFontSize: CGFloat = 14
|
var lineWidth: CGFloat = 7
|
||||||
|
|
||||||
private var progress: Double {
|
private var progress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(max(consumed / goal, 0), 1)
|
return min(current / goal, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remaining: Double {
|
||||||
|
max(goal - current, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress)
|
||||||
|
.stroke(
|
||||||
|
color,
|
||||||
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(Int(remaining))")
|
||||||
|
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Color.text1)
|
||||||
|
Text("left")
|
||||||
|
.font(.system(size: size * 0.13, weight: .medium))
|
||||||
|
.foregroundStyle(Color.text4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(Color.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacroRingLarge: View {
|
||||||
|
let current: Double
|
||||||
|
let goal: Double
|
||||||
|
let color: Color
|
||||||
|
var size: CGFloat = 120
|
||||||
|
var lineWidth: CGFloat = 10
|
||||||
|
|
||||||
|
private var progress: Double {
|
||||||
|
guard goal > 0 else { return 0 }
|
||||||
|
return min(current / goal, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remaining: Double {
|
||||||
|
max(goal - current, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: progress)
|
.trim(from: 0, to: progress)
|
||||||
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
.stroke(
|
||||||
|
color,
|
||||||
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.easeInOut(duration: 0.6), value: progress)
|
.animation(.easeOut(duration: 0.5), value: progress)
|
||||||
|
|
||||||
if showLabel {
|
VStack(spacing: 2) {
|
||||||
VStack(spacing: 0) {
|
Text("\(Int(remaining))")
|
||||||
Text("\(Int(consumed))")
|
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
|
||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.foregroundStyle(Color.text1)
|
||||||
.foregroundStyle(Color.textPrimary)
|
Text("remaining")
|
||||||
if goal > 0 {
|
.font(.system(size: size * 0.11, weight: .medium))
|
||||||
Text("/ \(Int(goal))")
|
.foregroundStyle(Color.text4)
|
||||||
.font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded))
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LargeCalorieRing: View {
|
|
||||||
let consumed: Double
|
|
||||||
let goal: Double
|
|
||||||
|
|
||||||
private var remaining: Int {
|
|
||||||
max(0, Int(goal) - Int(consumed))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var progress: Double {
|
|
||||||
guard goal > 0 else { return 0 }
|
|
||||||
return min(max(consumed / goal, 0), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.stroke(Color.emerald.opacity(0.15), lineWidth: 14)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.trim(from: 0, to: progress)
|
|
||||||
.stroke(
|
|
||||||
Color.emerald,
|
|
||||||
style: StrokeStyle(lineWidth: 14, lineCap: .round)
|
|
||||||
)
|
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
.animation(.easeInOut(duration: 0.8), value: progress)
|
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text("\(Int(consumed))")
|
|
||||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
Text("\(remaining) left")
|
|
||||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
|
// Warm palette matching web app
|
||||||
|
static let canvas = Color(hex: "F5EFE6")
|
||||||
|
static let surface = Color.white
|
||||||
|
static let surfaceSecondary = Color(hex: "F4F4F5")
|
||||||
|
static let cardBackground = Color.white
|
||||||
|
static let cardSecondary = Color(hex: "F4F4F5")
|
||||||
|
|
||||||
|
static let text1 = Color(hex: "18181B")
|
||||||
|
static let text2 = Color(hex: "3F3F46")
|
||||||
|
static let text3 = Color(hex: "71717A")
|
||||||
|
static let text4 = Color(hex: "A1A1AA")
|
||||||
|
|
||||||
|
// Accent — warm amber/brown
|
||||||
|
static let accentWarm = Color(hex: "8B6914")
|
||||||
|
static let accentWarmBg = Color(hex: "FEF7E6")
|
||||||
|
|
||||||
|
// Emerald accent from web
|
||||||
|
static let accentEmerald = Color(hex: "059669")
|
||||||
|
static let accentEmeraldBg = Color(hex: "ECFDF5")
|
||||||
|
|
||||||
|
// Semantic
|
||||||
|
static let success = Color(hex: "059669")
|
||||||
|
static let error = Color(hex: "DC2626")
|
||||||
|
static let warning = Color(hex: "D97706")
|
||||||
|
|
||||||
|
// Macro colors
|
||||||
|
static let caloriesColor = Color(hex: "8B6914")
|
||||||
|
static let proteinColor = Color(hex: "059669")
|
||||||
|
static let carbsColor = Color(hex: "3B82F6")
|
||||||
|
static let fatColor = Color(hex: "F59E0B")
|
||||||
|
static let sugarColor = Color(hex: "EC4899")
|
||||||
|
static let fiberColor = Color(hex: "8B5CF6")
|
||||||
|
|
||||||
|
// Meal colors
|
||||||
|
static let breakfast = Color(hex: "F59E0B")
|
||||||
|
static let lunch = Color(hex: "059669")
|
||||||
|
static let dinner = Color(hex: "3B82F6")
|
||||||
|
static let snack = Color(hex: "8B5CF6")
|
||||||
|
|
||||||
init(hex: String) {
|
init(hex: String) {
|
||||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
var int: UInt64 = 0
|
var int: UInt64 = 0
|
||||||
Scanner(string: hex).scanHexInt64(&int)
|
Scanner(string: hex).scanHexInt64(&int)
|
||||||
let a, r, g, b: UInt64
|
let a, r, g, b: UInt64
|
||||||
switch hex.count {
|
switch hex.count {
|
||||||
|
case 3:
|
||||||
|
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||||
case 6:
|
case 6:
|
||||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||||
case 8:
|
case 8:
|
||||||
@@ -23,25 +64,23 @@ extension Color {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core palette
|
static func mealColor(for meal: String) -> Color {
|
||||||
static let canvas = Color(hex: "F5EFE6")
|
switch meal.lowercased() {
|
||||||
static let surface = Color(hex: "FFFFFF")
|
case "breakfast": return .breakfast
|
||||||
static let surfaceSecondary = Color(hex: "FAF7F2")
|
case "lunch": return .lunch
|
||||||
static let accentWarm = Color(hex: "8B6914")
|
case "dinner": return .dinner
|
||||||
static let accentWarmLight = Color(hex: "D4A843")
|
case "snack": return .snack
|
||||||
static let emerald = Color(hex: "059669")
|
default: return .text3
|
||||||
static let textPrimary = Color(hex: "1C1917")
|
}
|
||||||
static let textSecondary = Color(hex: "78716C")
|
}
|
||||||
static let textTertiary = Color(hex: "A8A29E")
|
|
||||||
static let border = Color(hex: "E7E5E4")
|
|
||||||
|
|
||||||
// Meal colors
|
static func mealIcon(for meal: String) -> String {
|
||||||
static let breakfastColor = Color(hex: "F59E0B")
|
switch meal.lowercased() {
|
||||||
static let lunchColor = Color(hex: "059669")
|
case "breakfast": return "sunrise.fill"
|
||||||
static let dinnerColor = Color(hex: "8B5CF6")
|
case "lunch": return "sun.max.fill"
|
||||||
static let snackColor = Color(hex: "EC4899")
|
case "dinner": return "moon.fill"
|
||||||
|
case "snack": return "leaf.fill"
|
||||||
// Chat
|
default: return "fork.knife"
|
||||||
static let userBubble = Color(hex: "8B6914").opacity(0.15)
|
}
|
||||||
static let assistantBubble = Color(hex: "F5F5F4")
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Date {
|
extension Date {
|
||||||
|
/// Format as yyyy-MM-dd for API calls
|
||||||
var apiDateString: String {
|
var apiDateString: String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
@@ -8,15 +9,24 @@ extension Date {
|
|||||||
return formatter.string(from: self)
|
return formatter.string(from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display format: "Mon, Apr 2"
|
||||||
var displayString: String {
|
var displayString: String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "EEEE, MMM d"
|
formatter.dateFormat = "EEE, MMM d"
|
||||||
return formatter.string(from: self)
|
return formatter.string(from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full display: "Monday, April 2, 2026"
|
||||||
|
var fullDisplayString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .full
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short display: "Apr 2"
|
||||||
var shortDisplayString: String {
|
var shortDisplayString: String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "MMM d, yyyy"
|
formatter.dateFormat = "MMM d"
|
||||||
return formatter.string(from: self)
|
return formatter.string(from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +34,25 @@ extension Date {
|
|||||||
Calendar.current.isDateInToday(self)
|
Calendar.current.isDateInToday(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isYesterday: Bool {
|
||||||
|
Calendar.current.isDateInYesterday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(days: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
static func from(apiString: String) -> Date? {
|
static func from(apiString: String) -> Date? {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter.date(from: apiString)
|
return formatter.date(from: apiString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a label like "Today", "Yesterday", or the display string
|
||||||
|
var relativeLabel: String {
|
||||||
|
if isToday { return "Today" }
|
||||||
|
if isYesterday { return "Yesterday" }
|
||||||
|
return displayString
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user