feat: rebuild iOS app from API audit + new podcast/media service
All checks were successful
Security Checks / dependency-audit (push) Successful in 12s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s

iOS App (complete rebuild):
- Audited all fitness API endpoints against live responses
- Models match exact API field names (snapshot_ prefixes, UUID strings)
- FoodEntry uses computed properties (foodName, calories, etc.) wrapping snapshot fields
- Flexible Int/Double decoding for all numeric fields
- AI assistant with raw JSON state management (JSONSerialization, not Codable)
- Home dashboard with custom background, frosted glass calorie widget
- Fitness: Today/Templates/Goals/Foods tabs
- Food search with recent + all sections
- Meal sections with colored accent bars, swipe to delete
- 120fps ProMotion, iOS 17+ @Observable

Podcast/Media Service:
- FastAPI backend for podcast RSS + local audiobook folders
- Shows, episodes, playback progress, queue management
- RSS feed fetching with feedparser + ETag support
- Local folder scanning with mutagen for audio metadata
- HTTP Range streaming for local audio files
- Playback events logging (play/pause/seek/complete)
- Reuses brain's PostgreSQL + Redis
- media_ prefixed tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 02:36:43 -05:00
parent e350a354a3
commit 69af4b84a5
56 changed files with 4256 additions and 4620 deletions

View File

@@ -1,5 +0,0 @@
# This directory is a Syncthing folder marker.
# Do not delete.
folderID: pvf5v-v6cle
created: 2026-04-03T03:07:29Z

View File

@@ -1,467 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
F0819A9915F94DECBA67FA89 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */; };
DD3F166E894F43848CE426C7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42807C65E9754543B46DBF62 /* ContentView.swift */; };
C9B59C679DF04512BF9F5C69 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F2CEB00333491ABEE288C5 /* PlatformApp.swift */; };
F35DB630BE0A46799AEC15A5 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F8C700FA4E43818AA54E03 /* APIClient.swift */; };
B20540C8E87F42C2AF4AB477 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4094EAAB413433FA0D70AB9 /* AuthManager.swift */; };
779FB7AAB0244CB68E5C69C8 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879AEC4095F64FE7B851B6F9 /* LoginView.swift */; };
FB0A7F362F5944C0BF0365C5 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F36ACA2BA1243B0B8954E8F /* AssistantChatView.swift */; };
45E92CD7F85A49BFA5C05F20 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4073DF36CE74BB4A5B4F22A /* AssistantViewModel.swift */; };
2BC13A589E944CE3A4C6B708 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E149FC023D964ADA86C96EB5 /* FitnessAPI.swift */; };
F7F65E02FB974FACA76AFAF4 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4DF9C7493C2402A8B8571DB /* FitnessModels.swift */; };
CF63000DFF4F4F728281A31D /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D82B05FA8D84CA98E18C0E2 /* FitnessRepository.swift */; };
400DCD1DE0534E9E856319F8 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1822D8E4AFBF4B14A5E79050 /* FoodSearchViewModel.swift */; };
96AFE69ACFB54D97A7C3A4A0 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDED359C0EE4CC2986AE602 /* GoalsViewModel.swift */; };
84CFFF27A99940B5875A65DA /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FED646258BA4DFA8D6078EC /* TemplatesViewModel.swift */; };
03EBDF20EDF547EDB4B177CF /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F05CCCEE9A4E0FA6BC6795 /* TodayViewModel.swift */; };
92E276F3CA8D4400827B2F99 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C60F0E61463489689BC84E3 /* AddFoodSheet.swift */; };
C1D87BF5F61847BD952F5C65 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3C34A418B5428A8B2A62B7 /* EntryDetailView.swift */; };
E48F9E44708544D781FC8ACD /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D994E5BA694983BA293390 /* FitnessTabView.swift */; };
2F5AF62DD13D4B0EB8E338A0 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27103840483E431EB0275752 /* FoodLibraryView.swift */; };
6FE7AC0C375242398A5118B4 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98379942DAA4FB992CE1A33 /* FoodSearchView.swift */; };
8064B019D0B742749713A35E /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511884CFF6D40198C9A326B /* GoalsView.swift */; };
060E0115D90647A593BCF8B7 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA9E7B0D02A4E13ADD734A4 /* MealSectionView.swift */; };
7902B9F4789C4C6FB080AF0D /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E59243273F494E9C1F63CB /* TemplatesView.swift */; };
A74F6C2CDEF4487383684BB9 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE2113036D74BBA9D3DA571 /* TodayView.swift */; };
75DB623876D54D0BAF32B59F /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0067210C0C4833BEF98835 /* HomeView.swift */; };
340D12BB6F234169953639F6 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B0BE1ED854A1F859C8B6F /* HomeViewModel.swift */; };
D2CF33B6FE4142CA9995E125 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88C79EBC3A4E3791482B07 /* LoadingView.swift */; };
CF9C74396EA449D2BDEBAA09 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A32CB0269E4AF79A96B241 /* MacroBar.swift */; };
C6D6C2D5882245A895C8B606 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF6CAF2179B48C6B338233C /* MacroRing.swift */; };
86D08C3CC8B34AEDB1254EC1 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C04E1FF020544D08EDD3CCA /* Color+Extensions.swift */; };
C367EA266AED4AB7842B8A6F /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 929DC19B5C81454EB58087AA /* Date+Extensions.swift */; };
F1AA651AF622471EA697E2CA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C75844F44B444F4A8228158 /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Config.swift"; sourceTree = "<group>"; };
42807C65E9754543B46DBF62 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView.swift"; sourceTree = "<group>"; };
47F2CEB00333491ABEE288C5 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlatformApp.swift"; sourceTree = "<group>"; };
63F8C700FA4E43818AA54E03 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient.swift"; sourceTree = "<group>"; };
E4094EAAB413433FA0D70AB9 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthManager.swift"; sourceTree = "<group>"; };
879AEC4095F64FE7B851B6F9 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoginView.swift"; sourceTree = "<group>"; };
5F36ACA2BA1243B0B8954E8F /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssistantChatView.swift"; sourceTree = "<group>"; };
F4073DF36CE74BB4A5B4F22A /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssistantViewModel.swift"; sourceTree = "<group>"; };
E149FC023D964ADA86C96EB5 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessAPI.swift"; sourceTree = "<group>"; };
B4DF9C7493C2402A8B8571DB /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessModels.swift"; sourceTree = "<group>"; };
6D82B05FA8D84CA98E18C0E2 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessRepository.swift"; sourceTree = "<group>"; };
1822D8E4AFBF4B14A5E79050 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodSearchViewModel.swift"; sourceTree = "<group>"; };
DCDED359C0EE4CC2986AE602 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GoalsViewModel.swift"; sourceTree = "<group>"; };
4FED646258BA4DFA8D6078EC /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TemplatesViewModel.swift"; sourceTree = "<group>"; };
B4F05CCCEE9A4E0FA6BC6795 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TodayViewModel.swift"; sourceTree = "<group>"; };
8C60F0E61463489689BC84E3 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddFoodSheet.swift"; sourceTree = "<group>"; };
3C3C34A418B5428A8B2A62B7 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EntryDetailView.swift"; sourceTree = "<group>"; };
A5D994E5BA694983BA293390 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessTabView.swift"; sourceTree = "<group>"; };
27103840483E431EB0275752 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodLibraryView.swift"; sourceTree = "<group>"; };
A98379942DAA4FB992CE1A33 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodSearchView.swift"; sourceTree = "<group>"; };
A511884CFF6D40198C9A326B /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GoalsView.swift"; sourceTree = "<group>"; };
6EA9E7B0D02A4E13ADD734A4 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealSectionView.swift"; sourceTree = "<group>"; };
D0E59243273F494E9C1F63CB /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TemplatesView.swift"; sourceTree = "<group>"; };
3CE2113036D74BBA9D3DA571 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TodayView.swift"; sourceTree = "<group>"; };
FE0067210C0C4833BEF98835 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeView.swift"; sourceTree = "<group>"; };
F60B0BE1ED854A1F859C8B6F /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeViewModel.swift"; sourceTree = "<group>"; };
1D88C79EBC3A4E3791482B07 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoadingView.swift"; sourceTree = "<group>"; };
16A32CB0269E4AF79A96B241 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MacroBar.swift"; sourceTree = "<group>"; };
FDF6CAF2179B48C6B338233C /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MacroRing.swift"; sourceTree = "<group>"; };
1C04E1FF020544D08EDD3CCA /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
929DC19B5C81454EB58087AA /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
4C75844F44B444F4A8228158 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
0FDDDCE767CF4BF6B6D41677 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
4B7D1D629553482DA83FE35D /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
037C8FC2A4954FCE91D25A60 /* Platform */ = {
isa = PBXGroup;
children = (
029D94F090324446B082BA63 /* Platform */,
B5E96950287B4399909152DA /* Products */,
);
sourceTree = "<group>";
};
B5E96950287B4399909152DA /* Products */ = {
isa = PBXGroup;
children = (
4B7D1D629553482DA83FE35D /* Platform.app */,
);
sourceTree = "<group>";
};
029D94F090324446B082BA63 /* Platform */ = {
isa = PBXGroup;
children = (
AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */,
42807C65E9754543B46DBF62 /* ContentView.swift */,
47F2CEB00333491ABEE288C5 /* PlatformApp.swift */,
0FDDDCE767CF4BF6B6D41677 /* Info.plist */,
4C75844F44B444F4A8228158 /* Assets.xcassets */,
0DA26F997DC3429889C0B23A /* Core */,
8CF6CD4493114827807F5F6D /* Features */,
047E80495324497B8522ACEC /* Shared */,
);
path = "Platform"; sourceTree = "<group>";
};
0DA26F997DC3429889C0B23A /* Core */ = {
isa = PBXGroup;
children = (
,
);
path = "Core"; sourceTree = "<group>";
};
8CF6CD4493114827807F5F6D /* Features */ = {
isa = PBXGroup;
children = (
824CFF8CF00F41C590FB148C /* Auth */,
DAD6984656494252A7E8A5DC /* Home */,
C94148B12F3443238D763D27 /* Fitness */,
64DDC35730F64FAFA4F2962C /* Assistant */,
);
path = "Features"; sourceTree = "<group>";
};
824CFF8CF00F41C590FB148C /* Auth */ = {
isa = PBXGroup;
children = (
,
);
path = "Auth"; sourceTree = "<group>";
};
DAD6984656494252A7E8A5DC /* Home */ = {
isa = PBXGroup;
children = (
,
);
path = "Home"; sourceTree = "<group>";
};
64DDC35730F64FAFA4F2962C /* Assistant */ = {
isa = PBXGroup;
children = (
,
);
path = "Assistant"; sourceTree = "<group>";
};
C94148B12F3443238D763D27 /* Fitness */ = {
isa = PBXGroup;
children = (
822A533A33DF4047882688E2 /* Models */,
969F179A1EB645CCBAECE591 /* API */,
A5B64A87024F4F66B4A5D8B4 /* Repository */,
4B89164541C1493A80664F6D /* ViewModels */,
BB4E0BAFB7DA45A68F0480A4 /* Views */,
);
path = "Fitness"; sourceTree = "<group>";
};
969F179A1EB645CCBAECE591 /* API */ = {
isa = PBXGroup;
children = (
,
);
path = "API"; sourceTree = "<group>";
};
822A533A33DF4047882688E2 /* Models */ = {
isa = PBXGroup;
children = (
,
);
path = "Models"; sourceTree = "<group>";
};
A5B64A87024F4F66B4A5D8B4 /* Repository */ = {
isa = PBXGroup;
children = (
,
);
path = "Repository"; sourceTree = "<group>";
};
4B89164541C1493A80664F6D /* ViewModels */ = {
isa = PBXGroup;
children = (
,
);
path = "ViewModels"; sourceTree = "<group>";
};
BB4E0BAFB7DA45A68F0480A4 /* Views */ = {
isa = PBXGroup;
children = (
,
);
path = "Views"; sourceTree = "<group>";
};
047E80495324497B8522ACEC /* Shared */ = {
isa = PBXGroup;
children = (
7F73AF13180C459B8275CD39 /* Components */,
D3B81404D3A24B66B6848BB6 /* Extensions */,
);
path = "Shared"; sourceTree = "<group>";
};
7F73AF13180C459B8275CD39 /* Components */ = {
isa = PBXGroup;
children = (
,
);
path = "Components"; sourceTree = "<group>";
};
D3B81404D3A24B66B6848BB6 /* Extensions */ = {
isa = PBXGroup;
children = (
,
);
path = "Extensions"; sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
B66E7E9630EA415E95CE3A85 /* Platform */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0025AB77304B486EB7AE3B2B /* Build configuration list for PBXNativeTarget "Platform" */;
buildPhases = (
F8ADC26469734B15B38E123A /* Sources */,
078D546291EB4BBFA91F6661 /* Frameworks */,
841189B510034E9A81A14A8C /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Platform;
productName = Platform;
productReference = 4B7D1D629553482DA83FE35D /* Platform.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
1C4E1290ED4B4E0D832C6DD0 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
};
buildConfigurationList = E9E082A339DE4D0DAF491E2B /* Build configuration list for PBXProject "Platform" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 037C8FC2A4954FCE91D25A60 /* Platform */;
productRefGroup = B5E96950287B4399909152DA /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
B66E7E9630EA415E95CE3A85 /* Platform */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
841189B510034E9A81A14A8C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1AA651AF622471EA697E2CA /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F8ADC26469734B15B38E123A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F0819A9915F94DECBA67FA89 /* Config.swift in Sources */,
DD3F166E894F43848CE426C7 /* ContentView.swift in Sources */,
C9B59C679DF04512BF9F5C69 /* PlatformApp.swift in Sources */,
F35DB630BE0A46799AEC15A5 /* APIClient.swift in Sources */,
B20540C8E87F42C2AF4AB477 /* AuthManager.swift in Sources */,
779FB7AAB0244CB68E5C69C8 /* LoginView.swift in Sources */,
FB0A7F362F5944C0BF0365C5 /* AssistantChatView.swift in Sources */,
45E92CD7F85A49BFA5C05F20 /* AssistantViewModel.swift in Sources */,
2BC13A589E944CE3A4C6B708 /* FitnessAPI.swift in Sources */,
F7F65E02FB974FACA76AFAF4 /* FitnessModels.swift in Sources */,
CF63000DFF4F4F728281A31D /* FitnessRepository.swift in Sources */,
400DCD1DE0534E9E856319F8 /* FoodSearchViewModel.swift in Sources */,
96AFE69ACFB54D97A7C3A4A0 /* GoalsViewModel.swift in Sources */,
84CFFF27A99940B5875A65DA /* TemplatesViewModel.swift in Sources */,
03EBDF20EDF547EDB4B177CF /* TodayViewModel.swift in Sources */,
92E276F3CA8D4400827B2F99 /* AddFoodSheet.swift in Sources */,
C1D87BF5F61847BD952F5C65 /* EntryDetailView.swift in Sources */,
E48F9E44708544D781FC8ACD /* FitnessTabView.swift in Sources */,
2F5AF62DD13D4B0EB8E338A0 /* FoodLibraryView.swift in Sources */,
6FE7AC0C375242398A5118B4 /* FoodSearchView.swift in Sources */,
8064B019D0B742749713A35E /* GoalsView.swift in Sources */,
060E0115D90647A593BCF8B7 /* MealSectionView.swift in Sources */,
7902B9F4789C4C6FB080AF0D /* TemplatesView.swift in Sources */,
A74F6C2CDEF4487383684BB9 /* TodayView.swift in Sources */,
75DB623876D54D0BAF32B59F /* HomeView.swift in Sources */,
340D12BB6F234169953639F6 /* HomeViewModel.swift in Sources */,
D2CF33B6FE4142CA9995E125 /* LoadingView.swift in Sources */,
CF9C74396EA449D2BDEBAA09 /* MacroBar.swift in Sources */,
C6D6C2D5882245A895C8B606 /* MacroRing.swift in Sources */,
86D08C3CC8B34AEDB1254EC1 /* Color+Extensions.swift in Sources */,
C367EA266AED4AB7842B8A6F /* Date+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
078D546291EB4BBFA91F6661 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin XCBuildConfiguration section */
B7446285A53C413C9BA0229C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASCETECHNOLOGIES_AWARE = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_DYNAMIC_NO_PIC = NO;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
D697F1D80CFA4E629D58BE0A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
C29C67A4DC4943FB8112E677 /* 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 = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Platform/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
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_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
5289CC82B720470DA5F3181B /* 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 = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Platform/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
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_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
E9E082A339DE4D0DAF491E2B /* Build configuration list for PBXProject "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B7446285A53C413C9BA0229C /* Debug */,
D697F1D80CFA4E629D58BE0A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0025AB77304B486EB7AE3B2B /* Build configuration list for PBXNativeTarget "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C29C67A4DC4943FB8112E677 /* Debug */,
5289CC82B720470DA5F3181B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 1C4E1290ED4B4E0D832C6DD0 /* Project object */;
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -7,42 +7,41 @@
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 */; };
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 */; };
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 */; };
A10032 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10032 /* FoodLibraryView.swift */; };
A10033 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10033 /* AssistantChatView.swift */; };
A10034 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034 /* AssistantViewModel.swift */; };
A10001 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10001; };
A10002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10002; };
A10003 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10003; };
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004; };
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005; };
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006; };
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007; };
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008; };
A10009 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10009; };
A10010 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10010; };
A10011 /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10011; };
A10012 /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10012; };
A10013 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10013; };
A10014 /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10014; };
A10015 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10015; };
A10016 /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10016; };
A10017 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10017; };
A10018 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10018; };
A10019 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10019; };
A10020 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10020; };
A10021 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10021; };
A10022 /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10022; };
A10023 /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10023; };
A10024 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10024; };
A10025 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10025; };
A10026 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10026; };
A10027 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10027; };
A10028 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10028; };
A10029 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10029; };
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030; };
A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; };
A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; };
/* 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>"; };
@@ -54,31 +53,33 @@
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>"; };
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>"; };
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>"; };
B10012 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = "<group>"; };
B10013 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchViewModel.swift; sourceTree = "<group>"; };
B10014 /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesViewModel.swift; sourceTree = "<group>"; };
B10015 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsViewModel.swift; sourceTree = "<group>"; };
B10016 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessTabView.swift; sourceTree = "<group>"; };
B10017 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
B10018 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSectionView.swift; sourceTree = "<group>"; };
B10019 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchView.swift; sourceTree = "<group>"; };
B10020 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFoodSheet.swift; sourceTree = "<group>"; };
B10021 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodLibraryView.swift; sourceTree = "<group>"; };
B10022 /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesView.swift; sourceTree = "<group>"; };
B10023 /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsView.swift; sourceTree = "<group>"; };
B10024 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDetailView.swift; sourceTree = "<group>"; };
B10025 /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantChatView.swift; sourceTree = "<group>"; };
B10026 /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantViewModel.swift; sourceTree = "<group>"; };
B10027 /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroRing.swift; sourceTree = "<group>"; };
B10028 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroBar.swift; sourceTree = "<group>"; };
B10029 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.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>"; };
B10032 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodLibraryView.swift; sourceTree = "<group>"; };
B10033 /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantChatView.swift; sourceTree = "<group>"; };
B10034 /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantViewModel.swift; sourceTree = "<group>"; };
B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
C10001 /* Frameworks */ = {
E10001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -88,29 +89,30 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
G10000 = {
F10001 = {
isa = PBXGroup;
children = (
G10001 /* Platform */,
G10099 /* Products */,
F10002 /* Platform */,
F10020 /* Products */,
);
sourceTree = "<group>";
};
G10001 /* Platform */ = {
F10002 /* Platform */ = {
isa = PBXGroup;
children = (
B10001 /* PlatformApp.swift */,
B10002 /* ContentView.swift */,
B10003 /* Config.swift */,
B10031 /* Assets.xcassets */,
G10002 /* Core */,
G10003 /* Features */,
G10004 /* Shared */,
B10033 /* Info.plist */,
C10001 /* Assets.xcassets */,
F10003 /* Core */,
F10004 /* Features */,
F10015 /* Shared */,
);
path = Platform;
sourceTree = "<group>";
};
G10002 /* Core */ = {
F10003 /* Core */ = {
isa = PBXGroup;
children = (
B10004 /* APIClient.swift */,
@@ -119,46 +121,18 @@
path = Core;
sourceTree = "<group>";
};
G10003 /* Features */ = {
F10004 /* Features */ = {
isa = PBXGroup;
children = (
G10010 /* Auth */,
G10011 /* Home */,
G10012 /* Fitness */,
G10018 /* Assistant */,
F10005 /* Auth */,
F10006 /* Home */,
F10007 /* Fitness */,
F10014 /* Assistant */,
);
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 */ = {
F10005 /* Auth */ = {
isa = PBXGroup;
children = (
B10006 /* LoginView.swift */,
@@ -166,7 +140,7 @@
path = Auth;
sourceTree = "<group>";
};
G10011 /* Home */ = {
F10006 /* Home */ = {
isa = PBXGroup;
children = (
B10007 /* HomeView.swift */,
@@ -175,19 +149,19 @@
path = Home;
sourceTree = "<group>";
};
G10012 /* Fitness */ = {
F10007 /* Fitness */ = {
isa = PBXGroup;
children = (
G10013 /* Models */,
G10014 /* API */,
G10015 /* Repository */,
G10016 /* Views */,
G10017 /* ViewModels */,
F10008 /* Models */,
F10009 /* API */,
F10010 /* Repository */,
F10011 /* ViewModels */,
F10012 /* Views */,
);
path = Fitness;
sourceTree = "<group>";
};
G10013 /* Models */ = {
F10008 /* Models */ = {
isa = PBXGroup;
children = (
B10009 /* FitnessModels.swift */,
@@ -195,7 +169,7 @@
path = Models;
sourceTree = "<group>";
};
G10014 /* API */ = {
F10009 /* API */ = {
isa = PBXGroup;
children = (
B10010 /* FitnessAPI.swift */,
@@ -203,7 +177,7 @@
path = API;
sourceTree = "<group>";
};
G10015 /* Repository */ = {
F10010 /* Repository */ = {
isa = PBXGroup;
children = (
B10011 /* FitnessRepository.swift */,
@@ -211,46 +185,74 @@
path = Repository;
sourceTree = "<group>";
};
G10016 /* Views */ = {
F10011 /* ViewModels */ = {
isa = PBXGroup;
children = (
B10012 /* FitnessTabView.swift */,
B10013 /* TodayView.swift */,
B10014 /* MealSectionView.swift */,
B10015 /* FoodSearchView.swift */,
B10016 /* AddFoodSheet.swift */,
B10018 /* TemplatesView.swift */,
B10019 /* GoalsView.swift */,
B10020 /* EntryDetailView.swift */,
B10032 /* FoodLibraryView.swift */,
);
path = Views;
sourceTree = "<group>";
};
G10017 /* ViewModels */ = {
isa = PBXGroup;
children = (
B10021 /* TodayViewModel.swift */,
B10022 /* FoodSearchViewModel.swift */,
B10024 /* TemplatesViewModel.swift */,
B10025 /* GoalsViewModel.swift */,
B10012 /* TodayViewModel.swift */,
B10013 /* FoodSearchViewModel.swift */,
B10014 /* TemplatesViewModel.swift */,
B10015 /* GoalsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
G10018 /* Assistant */ = {
F10012 /* Views */ = {
isa = PBXGroup;
children = (
B10033 /* AssistantChatView.swift */,
B10034 /* AssistantViewModel.swift */,
B10016 /* FitnessTabView.swift */,
B10017 /* TodayView.swift */,
B10018 /* MealSectionView.swift */,
B10019 /* FoodSearchView.swift */,
B10020 /* AddFoodSheet.swift */,
B10021 /* FoodLibraryView.swift */,
B10022 /* TemplatesView.swift */,
B10023 /* GoalsView.swift */,
B10024 /* EntryDetailView.swift */,
);
path = Views;
sourceTree = "<group>";
};
F10014 /* Assistant */ = {
isa = PBXGroup;
children = (
B10025 /* AssistantChatView.swift */,
B10026 /* AssistantViewModel.swift */,
);
path = Assistant;
sourceTree = "<group>";
};
G10099 /* Products */ = {
F10015 /* Shared */ = {
isa = PBXGroup;
children = (
B10000 /* Platform.app */,
F10016 /* Components */,
F10017 /* Extensions */,
);
path = Shared;
sourceTree = "<group>";
};
F10016 /* Components */ = {
isa = PBXGroup;
children = (
B10027 /* MacroRing.swift */,
B10028 /* MacroBar.swift */,
B10029 /* LoadingView.swift */,
);
path = Components;
sourceTree = "<group>";
};
F10017 /* Extensions */ = {
isa = PBXGroup;
children = (
B10030 /* Color+Extensions.swift */,
B10031 /* Date+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
F10020 /* Products */ = {
isa = PBXGroup;
children = (
D10001 /* Platform.app */,
);
name = Products;
sourceTree = "<group>";
@@ -258,13 +260,13 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
T10001 /* Platform */ = {
G10001 /* Platform */ = {
isa = PBXNativeTarget;
buildConfigurationList = CL10002 /* Build configuration list for PBXNativeTarget "Platform" */;
buildConfigurationList = H10003 /* Build configuration list for PBXNativeTarget "Platform" */;
buildPhases = (
S10001 /* Sources */,
C10001 /* Frameworks */,
R10001 /* Resources */,
G10002 /* Sources */,
E10001 /* Frameworks */,
G10003 /* Resources */,
);
buildRules = (
);
@@ -272,25 +274,25 @@
);
name = Platform;
productName = Platform;
productReference = B10000 /* Platform.app */;
productReference = D10001 /* Platform.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
P10001 /* Project object */ = {
G10010 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
TargetAttributes = {
T10001 = {
G10001 = {
CreatedOnToolsVersion = 15.4;
};
};
};
buildConfigurationList = CL10001 /* Build configuration list for PBXProject "Platform" */;
buildConfigurationList = H10001 /* Build configuration list for PBXProject "Platform" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
@@ -298,29 +300,29 @@
en,
Base,
);
mainGroup = G10000;
productRefGroup = G10099 /* Products */;
mainGroup = F10001;
productRefGroup = F10020 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
T10001 /* Platform */,
G10001 /* Platform */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
R10001 /* Resources */ = {
G10003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10031 /* Assets.xcassets in Resources */,
A10032 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
S10001 /* Sources */ = {
G10002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -335,33 +337,33 @@
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 */,
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 */,
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 */,
A10012 /* TodayViewModel.swift in Sources */,
A10013 /* FoodSearchViewModel.swift in Sources */,
A10014 /* TemplatesViewModel.swift in Sources */,
A10015 /* GoalsViewModel.swift in Sources */,
A10016 /* FitnessTabView.swift in Sources */,
A10017 /* TodayView.swift in Sources */,
A10018 /* MealSectionView.swift in Sources */,
A10019 /* FoodSearchView.swift in Sources */,
A10020 /* AddFoodSheet.swift in Sources */,
A10021 /* FoodLibraryView.swift in Sources */,
A10022 /* TemplatesView.swift in Sources */,
A10023 /* GoalsView.swift in Sources */,
A10024 /* EntryDetailView.swift in Sources */,
A10025 /* AssistantChatView.swift in Sources */,
A10026 /* AssistantViewModel.swift in Sources */,
A10027 /* MacroRing.swift in Sources */,
A10028 /* MacroBar.swift in Sources */,
A10029 /* LoadingView.swift in Sources */,
A10030 /* Color+Extensions.swift in Sources */,
A10032 /* FoodLibraryView.swift in Sources */,
A10033 /* AssistantChatView.swift in Sources */,
A10034 /* AssistantViewModel.swift in Sources */,
A10031 /* Date+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
BC10001 /* Debug */ = {
H10010 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
@@ -414,6 +416,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -423,7 +426,7 @@
};
name = Debug;
};
BC10002 /* Release */ = {
H10011 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
@@ -470,6 +473,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -478,17 +482,17 @@
};
name = Release;
};
BC10003 /* Debug */ = {
H10012 /* 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;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Platform/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -501,25 +505,23 @@
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_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
BC10004 /* Release */ = {
H10013 /* 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;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Platform/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -532,8 +534,6 @@
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_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -543,25 +543,26 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CL10001 /* Build configuration list for PBXProject "Platform" */ = {
H10001 /* Build configuration list for PBXProject "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BC10001 /* Debug */,
BC10002 /* Release */,
H10010 /* Debug */,
H10011 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CL10002 /* Build configuration list for PBXNativeTarget "Platform" */ = {
H10003 /* Build configuration list for PBXNativeTarget "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BC10003 /* Debug */,
BC10004 /* Release */,
H10012 /* Debug */,
H10013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = P10001 /* Project object */;
rootObject = G10010 /* Project object */;
}

View File

@@ -1,575 +0,0 @@
// !$*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 */; };
A10032 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10032 /* FoodLibraryView.swift */; };
F2B322572F7F89B600368ED5 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B322562F7F89B600368ED5 /* AssistantChatView.swift */; };
F2B322592F7F89CC00368ED5 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B322582F7F89CC00368ED5 /* AssistantViewModel.swift */; };
F2B3225B2F7F89DC00368ED5 /* AssistantModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B3225A2F7F89DC00368ED5 /* AssistantModels.swift */; };
F2B3225D2F7F89EF00368ED5 /* ImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B3225C2F7F89EF00368ED5 /* ImageCropView.swift */; };
/* 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>"; };
B10032 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodLibraryView.swift; sourceTree = "<group>"; };
F2B322562F7F89B600368ED5 /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AssistantChatView.swift; path = Platform/Features/Assistant/AssistantChatView.swift; sourceTree = "<group>"; };
F2B322582F7F89CC00368ED5 /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AssistantViewModel.swift; path = Platform/Features/Assistant/AssistantViewModel.swift; sourceTree = "<group>"; };
F2B3225A2F7F89DC00368ED5 /* AssistantModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AssistantModels.swift; path = Platform/Features/Assistant/Models/AssistantModels.swift; sourceTree = "<group>"; };
F2B3225C2F7F89EF00368ED5 /* ImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ImageCropView.swift; path = Platform/Features/Home/ImageCropView.swift; 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 = (
F2B3225C2F7F89EF00368ED5 /* ImageCropView.swift */,
F2B3225A2F7F89DC00368ED5 /* AssistantModels.swift */,
F2B322582F7F89CC00368ED5 /* AssistantViewModel.swift */,
F2B322562F7F89B600368ED5 /* AssistantChatView.swift */,
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 */,
B10032 /* FoodLibraryView.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 */,
F2B322592F7F89CC00368ED5 /* AssistantViewModel.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 */,
F2B322572F7F89B600368ED5 /* AssistantChatView.swift in Sources */,
A10008 /* HomeViewModel.swift in Sources */,
F2B3225B2F7F89DC00368ED5 /* AssistantModels.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 */,
F2B3225D2F7F89EF00368ED5 /* ImageCropView.swift in Sources */,
A10032 /* FoodLibraryView.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 */;
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
</Workspace>

View File

@@ -1,15 +1,7 @@
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)
}
static let gatewayURL = "https://dash.quadjourney.com"
static let appName = "Platform"
static let appVersion = "1.0.0"
}

View File

@@ -1,37 +1,90 @@
import SwiftUI
struct ContentView: View {
@Environment(AuthManager.self) private var authManager
@Environment(AuthManager.self) private var auth
var body: some View {
Group {
if authManager.isCheckingAuth {
LoadingView(message: "Checking session...")
} else if authManager.isLoggedIn {
if auth.isCheckingAuth {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
} else if auth.isLoggedIn {
MainTabView()
} else {
LoginView()
}
}
.task {
await authManager.checkAuth()
await auth.checkAuth()
}
}
}
struct MainTabView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
@State private var selectedTab = 0
@State private var showAssistant = false
FitnessTabView()
.tabItem {
Label("Fitness", systemImage: "flame.fill")
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
FitnessTabView()
.tabItem {
Label("Fitness", systemImage: "flame.fill")
}
.tag(1)
}
.tint(Color.accentWarm)
Button {
showAssistant = true
} label: {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentWarm)
.clipShape(Circle())
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 70)
}
.sheet(isPresented: $showAssistant) {
AssistantSheetView()
}
.tint(Color.accentWarm)
}
}
struct AssistantSheetView: View {
@State private var selectedMode = 0
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("Mode", selection: $selectedMode) {
Text("AI Chat").tag(0)
Text("Quick Add").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.top, 8)
if selectedMode == 0 {
AssistantChatView()
} else {
FoodSearchView(isSheet: true)
}
}
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.large])
}
}

View File

@@ -1,19 +1,21 @@
import Foundation
enum APIError: LocalizedError {
enum APIError: Error, LocalizedError {
case invalidURL
case httpError(Int, String?)
case httpError(Int, String)
case decodingError(Error)
case networkError(Error)
case unknown(String)
case unauthorized
case unknown
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .httpError(let code, let msg): return msg ?? "HTTP error \(code)"
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
case .decodingError(let err): return "Decoding error: \(err.localizedDescription)"
case .networkError(let err): return err.localizedDescription
case .unknown(let msg): return msg
case .unauthorized: return "Unauthorized"
case .unknown: return "Unknown error"
}
}
}
@@ -23,35 +25,63 @@ final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let baseURL: String
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private init() {
baseURL = Config.gatewayURL
let config = URLSessionConfiguration.default
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.httpCookieStorage = .shared
config.httpCookieStorage = HTTPCookieStorage.shared
session = URLSession(configuration: config)
encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
private func buildURL(_ path: String) throws -> URL {
guard let url = URL(string: "\(Config.gatewayURL)\(path)") else {
throw APIError.invalidURL
// MARK: - Generic Request
func request<T: Decodable>(
_ method: String,
path: String,
body: (any Encodable)? = nil,
queryItems: [URLQueryItem]? = nil
) async throws -> T {
let data = try await rawRequest(method, path: path, body: body, queryItems: queryItems)
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
return url
}
func request<T: Decodable>(_ method: String, _ path: String, body: Encodable? = nil) async throws -> T {
let url = try buildURL(path)
func rawRequest(
_ method: String,
path: String,
body: (any Encodable)? = nil,
queryItems: [URLQueryItem]? = nil
) async throws -> Data {
guard var components = URLComponents(string: baseURL + path) else {
throw APIError.invalidURL
}
if let queryItems, !queryItems.isEmpty {
components.queryItems = queryItems
}
guard let url = components.url else {
throw APIError.invalidURL
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let body = body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
if let body {
req.httpBody = try encoder.encode(body)
}
@@ -62,83 +92,93 @@ final class APIClient {
throw APIError.networkError(error)
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let bodyStr = String(data: data, encoding: .utf8)
throw APIError.httpError(httpResponse.statusCode, bodyStr)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.unknown
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
func get<T: Decodable>(_ path: String) async throws -> T {
try await request("GET", path)
}
func post<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
try await request("POST", path, body: body)
}
func patch<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
try await request("PATCH", path, body: body)
}
func put<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
try await request("PUT", path, body: body)
}
func delete(_ path: String) async throws {
let url = try buildURL(path)
var req = URLRequest(url: url)
req.httpMethod = "DELETE"
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch {
throw APIError.networkError(error)
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let bodyStr = String(data: data, encoding: .utf8)
throw APIError.httpError(httpResponse.statusCode, bodyStr)
guard (200...299).contains(httpResponse.statusCode) else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
throw APIError.httpError(httpResponse.statusCode, msg)
}
return data
}
func rawPost(_ path: String, body: Data) async throws -> (Data, URLResponse) {
let url = try buildURL(path)
// 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
) async throws -> T {
try await request("POST", path: path, body: body)
}
func patch<T: Decodable>(
_ path: String,
body: any Encodable
) async throws -> T {
try await request("PATCH", path: path, body: body)
}
func put<T: Decodable>(
_ path: String,
body: any Encodable
) async throws -> T {
try await request("PUT", path: path, body: body)
}
func delete<T: Decodable>(_ path: String) async throws -> T {
try await request("DELETE", path: path)
}
// MARK: - Raw POST (for assistant sends/receives raw Data)
func rawPost(path: String, data: Data) async throws -> Data {
guard let url = URL(string: baseURL + path) else {
throw APIError.invalidURL
}
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = body
req.httpBody = data
let (data, response): (Data, URLResponse)
let (responseData, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
(responseData, response) = try await session.data(for: req)
} catch {
throw APIError.networkError(error)
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let bodyStr = String(data: data, encoding: .utf8)
throw APIError.httpError(httpResponse.statusCode, bodyStr)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.unknown
}
return (data, response)
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
return responseData
}
// MARK: - Cookie Management
func clearCookies() {
if let cookies = HTTPCookieStorage.shared.cookies {
guard let url = URL(string: baseURL) else { return }
let storage = HTTPCookieStorage.shared
if let cookies = storage.cookies(for: url) {
for cookie in cookies {
HTTPCookieStorage.shared.deleteCookie(cookie)
storage.deleteCookie(cookie)
}
}
}

View File

@@ -1,52 +1,11 @@
import Foundation
struct AuthUser: Codable {
let id: Int
let username: String
let displayName: String?
enum CodingKeys: String, CodingKey {
case id, username
case displayName = "display_name"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Handle id as Int or String
if let intId = try? container.decode(Int.self, forKey: .id) {
id = intId
} else if let strId = try? container.decode(String.self, forKey: .id),
let parsed = Int(strId) {
id = parsed
} else {
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [CodingKeys.id], debugDescription: "Expected Int or String for id"))
}
username = try container.decode(String.self, forKey: .username)
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
}
}
struct LoginRequest: Encodable {
let username: String
let password: String
}
struct LoginResponse: Decodable {
let success: Bool
let user: AuthUser?
}
struct MeResponse: Decodable {
let authenticated: Bool
let user: AuthUser?
}
@Observable
final class AuthManager {
var isLoggedIn = false
var isCheckingAuth = true
var user: AuthUser?
var loginError: String?
var currentUser: GatewayUser?
var error: String?
private let api = APIClient.shared
private let loggedInKey = "isLoggedIn"
@@ -55,6 +14,27 @@ final class AuthManager {
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
}
struct LoginRequest: Encodable {
let username: String
let password: String
}
struct LoginResponse: Decodable {
let success: Bool
let user: GatewayUser
}
struct AuthCheckResponse: Decodable {
let authenticated: Bool
let user: GatewayUser?
}
struct GatewayUser: Decodable, Sendable {
let id: Int
let username: String
let displayName: String
}
func checkAuth() async {
guard UserDefaults.standard.bool(forKey: loggedInKey) else {
isCheckingAuth = false
@@ -62,9 +42,9 @@ final class AuthManager {
return
}
do {
let response: MeResponse = try await api.get("/api/auth/me")
if response.authenticated {
user = response.user
let response: AuthCheckResponse = try await api.get("/api/auth/me")
if response.authenticated, let user = response.user {
currentUser = user
isLoggedIn = true
} else {
isLoggedIn = false
@@ -78,27 +58,30 @@ final class AuthManager {
}
func login(username: String, password: String) async {
loginError = nil
error = nil
do {
let response: LoginResponse = try await api.post("/api/auth/login", body: LoginRequest(username: username, password: password))
let response: LoginResponse = try await api.post(
"/api/auth/login",
body: LoginRequest(username: username, password: password)
)
if response.success {
user = response.user
currentUser = response.user
isLoggedIn = true
UserDefaults.standard.set(true, forKey: loggedInKey)
} else {
loginError = "Invalid credentials"
}
} catch let error as APIError {
loginError = error.localizedDescription
} catch let apiError as APIError {
error = apiError.localizedDescription
} catch {
loginError = error.localizedDescription
self.error = error.localizedDescription
}
}
func logout() {
func logout() async {
struct LogoutResponse: Decodable { let success: Bool }
_ = try? await api.post("/api/auth/logout", body: [String: String]()) as LogoutResponse
api.clearCookies()
currentUser = nil
isLoggedIn = false
user = nil
UserDefaults.standard.set(false, forKey: loggedInKey)
}
}

View File

@@ -1,285 +1,224 @@
import SwiftUI
import PhotosUI
enum AssistantTab: String, CaseIterable {
case chat = "AI Chat"
case quickAdd = "Quick Add"
}
struct AssistantChatView: View {
let entryDate: String
let onDismiss: () -> Void
@Environment(AuthManager.self) private var authManager
@Environment(\.dismiss) private var dismiss
@State private var vm: AssistantViewModel
@State private var selectedTab: AssistantTab = .chat
@State private var scrollProxy: ScrollViewProxy?
init(entryDate: String, onDismiss: @escaping () -> Void) {
self.entryDate = entryDate
self.onDismiss = onDismiss
_vm = State(initialValue: AssistantViewModel(entryDate: entryDate, username: nil))
}
@State private var vm = AssistantViewModel()
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Tab switcher
HStack(spacing: 0) {
ForEach(AssistantTab.allCases, id: \.self) { tab in
Button {
selectedTab = tab
} label: {
Text(tab.rawValue)
.font(.subheadline.bold())
.foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selectedTab == tab ? Color.accentWarm.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.padding(4)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.top, 8)
if selectedTab == .chat {
chatContent
} else {
FoodSearchView(mealType: .snack, dateString: entryDate)
}
}
.background(Color.canvas)
.navigationTitle("Assistant")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
onDismiss()
}
}
}
.onAppear {
vm = AssistantViewModel(entryDate: entryDate, username: authManager.user?.username)
}
}
}
private var chatContent: some View {
VStack(spacing: 0) {
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
LazyVStack(spacing: 8) {
ForEach(vm.messages) { message in
messageBubble(message)
chatBubble(message)
.id(message.id)
}
// Draft card
if let draft = vm.currentDraft, !vm.applied {
draftCard(draft)
}
// Multiple drafts
if vm.currentDrafts.count > 1 && !vm.applied {
multipleDraftsCard
}
if vm.isLoading {
HStack {
ProgressView()
.tint(Color.accentWarm)
.controlSize(.small)
Text("Thinking...")
.font(.caption)
.foregroundStyle(Color.textSecondary)
.foregroundStyle(Color.textTertiary)
Spacer()
}
.padding(.horizontal, 16)
.id("loading")
.padding(.horizontal)
}
if let error = vm.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal)
}
}
.padding(.vertical, 12)
.padding(.vertical, 8)
}
.onAppear { scrollProxy = proxy }
.onChange(of: vm.messages.count) { _, _ in
withAnimation {
proxy.scrollTo(vm.messages.last?.id, anchor: .bottom)
.onChange(of: vm.messages.count) {
if let last = vm.messages.last {
withAnimation {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
if let error = vm.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal, 16)
.padding(.bottom, 4)
}
Divider()
// Input bar
HStack(spacing: 8) {
PhotosPicker(selection: Binding(
get: { vm.selectedPhoto },
set: { newVal in
vm.selectedPhoto = newVal
Task { await vm.loadPhoto(newVal) }
}
), matching: .images) {
Image(systemName: vm.photoData != nil ? "photo.fill" : "photo")
HStack(spacing: 10) {
PhotosPicker(selection: $vm.selectedPhoto, matching: .images) {
Image(systemName: "camera.fill")
.font(.title3)
.foregroundStyle(vm.photoData != nil ? Color.accentWarm : Color.textSecondary)
.foregroundStyle(Color.accentWarm)
}
TextField("Ask anything...", text: $vm.inputText)
TextField("Describe your food...", text: $vm.inputText)
.textFieldStyle(.plain)
.padding(10)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 20))
.onSubmit {
Task { await vm.send() }
}
Button {
Task {
await vm.send()
withAnimation {
scrollProxy?.scrollTo(vm.messages.last?.id, anchor: .bottom)
}
}
Task { await vm.send() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accentWarm)
.foregroundStyle(
vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty
? Color.textTertiary
: Color.accentWarm
)
}
.disabled(vm.inputText.isEmpty && vm.photoData == nil)
.disabled(vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty || vm.isLoading)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.surface)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.surfaceCard)
}
.onChange(of: vm.selectedPhoto) {
Task { await vm.handlePhotoSelection() }
}
}
@ViewBuilder
private func messageBubble(_ message: ChatMessage) -> some View {
if message.role == "user" {
HStack {
Spacer()
Text(message.content)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
.padding(12)
.background(Color(hex: "8B6914").opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 16))
.frame(maxWidth: 280, alignment: .trailing)
}
.padding(.horizontal, 16)
} else {
VStack(alignment: .leading, spacing: 8) {
if !message.content.isEmpty {
Text(message.content)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
.padding(12)
.background(Color.assistantBubble)
.clipShape(RoundedRectangle(cornerRadius: 16))
.frame(maxWidth: 300, alignment: .leading)
}
// MARK: - Chat Bubble
// Draft cards
ForEach(Array(message.drafts.enumerated()), id: \.offset) { _, draft in
draftCard(draft, applied: message.applied)
}
private func chatBubble(_ message: ChatMessage) -> some View {
HStack {
if message.role == "user" { Spacer(minLength: 60) }
// Source links
if !message.sources.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(message.sources) { source in
if let url = URL(string: source.href), !source.href.isEmpty {
Link(destination: url) {
sourceChip(source)
}
} else {
sourceChip(source)
}
}
}
}
}
}
.padding(.horizontal, 16)
Text(message.content)
.font(.subheadline)
.foregroundStyle(message.role == "user" ? .white : Color.textPrimary)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
message.role == "user"
? Color.accentWarm
: Color.surfaceSheet
)
.clipShape(RoundedRectangle(cornerRadius: 16))
if message.role == "assistant" { Spacer(minLength: 60) }
}
.padding(.horizontal, 12)
}
private func draftCard(_ draft: FitnessDraft, applied: Bool) -> some View {
// MARK: - Draft Card
private func draftCard(_ draft: FitnessDraft) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(draft.foodName)
.font(.subheadline.bold())
.foregroundStyle(Color.textPrimary)
Spacer()
Text(draft.mealType.capitalized)
.font(.caption2.bold())
Image(systemName: "doc.text.fill")
.foregroundStyle(Color.accentWarm)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.accentWarm.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
Text("Draft")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
}
HStack(spacing: 12) {
macroChip("Cal", Int(draft.calories))
macroChip("P", Int(draft.protein))
macroChip("C", Int(draft.carbs))
macroChip("F", Int(draft.fat))
Text(draft.foodName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
HStack(spacing: 16) {
miniStat("Cal", value: Int(draft.calories))
miniStat("P", value: Int(draft.protein))
miniStat("C", value: Int(draft.carbs))
miniStat("F", value: Int(draft.fat))
}
if !applied {
HStack(spacing: 8) {
Button {
Task { await vm.applyDraft() }
} label: {
Text("Add it")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.emerald)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
} else {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.emerald)
Text("Added")
.font(.caption.bold())
.foregroundStyle(Color.emerald)
}
HStack {
Text("\(draft.mealType.capitalized) \u{2022} \(draft.quantity, specifier: "%.1f") \(draft.unit)")
.font(.caption)
.foregroundStyle(Color.textSecondary)
Spacer()
}
Button {
Task { await vm.applyDraft() }
} label: {
Text("Add it")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.emerald)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding(12)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
.frame(maxWidth: 300, alignment: .leading)
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
.padding(.horizontal, 12)
}
private func macroChip(_ label: String, _ value: Int) -> some View {
private var multipleDraftsCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "doc.on.doc.fill")
.foregroundStyle(Color.accentWarm)
Text("\(vm.currentDrafts.count) Items")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
}
ForEach(vm.currentDrafts) { draft in
HStack {
Text(draft.foodName)
.font(.caption.weight(.medium))
.foregroundStyle(Color.textPrimary)
Spacer()
Text("\(Int(draft.calories)) kcal")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
}
let totalCals = vm.currentDrafts.reduce(0.0) { $0 + $1.calories }
Text("Total: \(Int(totalCals)) kcal")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.textSecondary)
Button {
Task { await vm.applyAllDrafts() }
} label: {
Text("Add all")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.emerald)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
.padding(.horizontal, 12)
}
private func miniStat(_ label: String, value: Int) -> some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.caption.bold())
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
Text(label)
.font(.system(size: 9))
.foregroundStyle(Color.textSecondary)
.font(.system(size: 9).weight(.medium))
.foregroundStyle(Color.textTertiary)
}
}
private func sourceChip(_ source: SourceLink) -> some View {
HStack(spacing: 4) {
Image(systemName: source.type == "brain" ? "brain" : "link")
.font(.system(size: 10))
Text(source.title)
.font(.caption2)
.lineLimit(1)
}
.foregroundStyle(Color.accentWarm)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.accentWarm.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}

View File

@@ -1,13 +1,12 @@
import SwiftUI
import Foundation
import PhotosUI
import SwiftUI
struct ChatMessage: Identifiable {
let id = UUID()
let role: String // "user" or "assistant"
let content: String
var drafts: [FitnessDraft] = []
var sources: [SourceLink] = []
var applied: Bool = false
let timestamp = Date()
}
@Observable
@@ -16,115 +15,129 @@ final class AssistantViewModel {
var inputText = ""
var isLoading = false
var error: String?
var selectedPhoto: PhotosPickerItem?
var photoData: Data?
var entryDate = Date()
// Raw JSON state from server never decode this
// State from server stored as raw JSON, never decoded with Codable
private var serverState: Any?
private let api = APIClient.shared
private var entryDate: String
private var allowBrain: Bool
init(entryDate: String, username: String?) {
self.entryDate = entryDate
self.allowBrain = (username ?? "") != "madiha"
// Draft data parsed manually from raw dict
var currentDraft: FitnessDraft?
var currentDrafts: [FitnessDraft] = []
var applied = false
// Photo
var selectedPhoto: PhotosPickerItem?
var imageDataUrl: String?
private let api = FitnessAPI()
var dateString: String {
entryDate.apiDateString
}
func send(action: String = "chat") async {
let text = inputText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty || action == "apply" else { return }
// MARK: - Send Message
if action == "chat" && !text.isEmpty {
messages.append(ChatMessage(role: "user", content: text))
inputText = ""
}
func send() async {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
messages.append(ChatMessage(role: "user", content: text))
inputText = ""
isLoading = true
error = nil
applied = false
await doSend(action: "chat")
}
func applyDraft() async {
isLoading = true
error = nil
await doSend(action: "apply")
}
func applyAllDrafts() async {
isLoading = true
error = nil
await doSend(action: "apply")
}
private func doSend(action: String) async {
do {
// Build request as raw JSON
// Build message array for API
let msgArray: [[String: String]] = messages.map { msg in
["role": msg.role, "content": msg.content]
}
// Build request as raw dictionary
var requestDict: [String: Any] = [
"entryDate": entryDate,
"messages": msgArray,
"action": action,
"allowBrain": allowBrain
"entryDate": dateString,
"allowBrain": false,
]
// Messages array
let msgArray = messages.filter { $0.role == "user" }.map { msg -> [String: String] in
["role": "user", "content": msg.content]
}
requestDict["messages"] = msgArray
// State pass-through
if let state = serverState {
requestDict["state"] = state
} else {
requestDict["state"] = NSNull()
}
// Photo
if let data = photoData {
let base64 = data.base64EncodedString()
requestDict["imageDataUrl"] = "data:image/jpeg;base64,\(base64)"
photoData = nil
} else {
requestDict["imageDataUrl"] = NSNull()
if let draft = currentDraft {
requestDict["draft"] = draftToDict(draft)
}
let bodyData = try JSONSerialization.data(withJSONObject: requestDict)
let (responseData, _) = try await api.rawPost("/api/assistant/chat", body: bodyData)
if !currentDrafts.isEmpty {
requestDict["drafts"] = currentDrafts.map { draftToDict($0) }
}
// Parse response as raw JSON
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
error = "Invalid response"
if let imageUrl = imageDataUrl {
requestDict["imageDataUrl"] = imageUrl
imageDataUrl = nil
}
let requestData = try JSONSerialization.data(withJSONObject: requestDict)
let responseData = try await api.sendAssistantMessage(data: requestData)
// Parse response as raw dict
guard let responseDict = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
error = "Invalid response from server"
isLoading = false
return
}
// Store raw state
serverState = json["state"]
// Extract display fields
let reply = json["reply"] as? String ?? ""
let applied = json["applied"] as? Bool ?? false
// Parse drafts
var drafts: [FitnessDraft] = []
if let draft = json["draft"] as? [String: Any], let d = FitnessDraft(from: draft) {
drafts.append(d)
if let reply = responseDict["reply"] as? String, !reply.isEmpty {
messages.append(ChatMessage(role: "assistant", content: reply))
}
if let draftsArray = json["drafts"] as? [[String: Any]] {
for dict in draftsArray {
if let d = FitnessDraft(from: dict) {
drafts.append(d)
}
// Store state as raw
if let state = responseDict["state"] {
serverState = state
}
// Parse draft
if let draftDict = responseDict["draft"] as? [String: Any] {
currentDraft = FitnessDraft(from: draftDict)
}
// Parse drafts array
if let draftsArray = responseDict["drafts"] as? [[String: Any]] {
currentDrafts = draftsArray.map { FitnessDraft(from: $0) }
if currentDraft == nil && !currentDrafts.isEmpty {
currentDraft = currentDrafts.first
}
}
// Parse sources
var sources: [SourceLink] = []
if let sourcesArray = json["sources"] as? [[String: Any]] {
for dict in sourcesArray {
if let s = SourceLink(from: dict) {
sources.append(s)
}
// Check applied
if let appliedValue = responseDict["applied"] as? Bool {
applied = appliedValue
if appliedValue {
currentDraft = nil
currentDrafts = []
}
}
// Check for error
if let errStr = json["error"] as? String, !errStr.isEmpty {
error = errStr
}
if !reply.isEmpty || !drafts.isEmpty {
messages.append(ChatMessage(
role: "assistant",
content: reply,
drafts: drafts,
sources: sources,
applied: applied
))
if let errorMsg = responseDict["error"] as? String {
error = errorMsg
}
} catch {
@@ -134,17 +147,60 @@ final class AssistantViewModel {
isLoading = false
}
func applyDraft() async {
await send(action: "apply")
// MARK: - Photo handling
func handlePhotoSelection() async {
guard let item = selectedPhoto else { return }
guard let data = try? await item.loadTransferable(type: Data.self) else { return }
// Resize for upload
guard let image = UIImage(data: data) else { return }
let maxDim: CGFloat = 800
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
if let jpegData = resized.jpegData(compressionQuality: 0.7) {
let base64 = jpegData.base64EncodedString()
imageDataUrl = "data:image/jpeg;base64,\(base64)"
messages.append(ChatMessage(role: "user", content: "[Photo attached]"))
await doSend(action: "chat")
}
selectedPhoto = nil
}
func loadPhoto(_ item: PhotosPickerItem?) async {
guard let item else { return }
if let data = try? await item.loadTransferable(type: Data.self) {
// Compress as JPEG
if let img = UIImage(data: data), let jpeg = img.jpegData(compressionQuality: 0.7) {
photoData = jpeg
}
}
// MARK: - Helpers
private func draftToDict(_ draft: FitnessDraft) -> [String: Any] {
[
"food_name": draft.foodName,
"meal_type": draft.mealType,
"entry_date": draft.entryDate,
"quantity": draft.quantity,
"unit": draft.unit,
"calories": draft.calories,
"protein": draft.protein,
"carbs": draft.carbs,
"fat": draft.fat,
"sugar": draft.sugar,
"fiber": draft.fiber,
"note": draft.note,
"default_serving_label": draft.defaultServingLabel,
]
}
func reset() {
messages = []
inputText = ""
serverState = nil
currentDraft = nil
currentDrafts = []
applied = false
error = nil
imageDataUrl = nil
}
}

View File

@@ -1,158 +1,73 @@
import SwiftUI
struct LoginView: View {
@Environment(AuthManager.self) private var authManager
@Environment(AuthManager.self) private var auth
@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()
VStack(spacing: 32) {
Spacer()
ScrollView {
VStack(spacing: 32) {
Spacer()
.frame(height: 60)
VStack(spacing: 8) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 48))
.foregroundStyle(Color.accentWarm)
Text("Platform")
.font(.largeTitle.weight(.bold))
.foregroundStyle(Color.textPrimary)
Text("Sign in to your account")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
// Logo / Branding
VStack(spacing: 8) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 48))
.foregroundStyle(Color.accentWarm)
VStack(spacing: 16) {
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
Text("Platform")
.font(.largeTitle.weight(.bold))
.foregroundStyle(Color.text1)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
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()
if let error = auth.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
.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
}
Button {
isLoading = true
Task {
await auth.login(username: username, password: password)
isLoading = false
}
} label: {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text("Sign In")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Color.accentWarm)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
.disabled(username.isEmpty || password.isEmpty || isLoading)
.opacity(username.isEmpty || password.isEmpty ? 0.6 : 1)
}
.padding(.horizontal, 32)
private func performLogin() {
guard canSubmit else { return }
isLoading = true
focusedField = nil
Task {
await authManager.login(
username: username.trimmingCharacters(in: .whitespaces),
password: password
)
isLoading = false
Spacer()
Spacer()
}
.background(Color.canvas)
}
}

View File

@@ -2,55 +2,84 @@ import Foundation
struct FitnessAPI {
private let api = APIClient.shared
private let basePath = "/api/fitness"
// MARK: - Entries
func getEntries(date: String) async throws -> [FoodEntry] {
try await api.get("/api/fitness/entries?date=\(date)")
try await api.get(
"\(basePath)/entries",
queryItems: [URLQueryItem(name: "date", value: date)]
)
}
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
try await api.post("/api/fitness/entries", body: req)
func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry {
try await api.post("\(basePath)/entries", body: request)
}
func updateEntry(id: String, quantity: Double) async throws -> FoodEntry {
struct Body: Encodable { let quantity: Double }
return try await api.patch("/api/fitness/entries/\(id)", body: Body(quantity: quantity))
func deleteEntry(id: String) async throws -> SuccessResponse {
try await api.delete("\(basePath)/entries/\(id)")
}
func deleteEntry(id: String) async throws {
try await api.delete("/api/fitness/entries/\(id)")
// MARK: - Goals
func getGoalsForDate(date: String) async throws -> DailyGoal {
try await api.get(
"\(basePath)/goals/for-date",
queryItems: [URLQueryItem(name: "date", value: date)]
)
}
func getFoods(limit: Int = 100) async throws -> [FoodItem] {
try await api.get("/api/fitness/foods?limit=\(limit)")
// MARK: - Foods
func getFoods(limit: Int = 100) async throws -> [Food] {
try await api.get(
"\(basePath)/foods",
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
)
}
func searchFoods(query: String, limit: Int = 20) async throws -> [FoodItem] {
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
return try await api.get("/api/fitness/foods/search?q=\(encoded)&limit=\(limit)")
func searchFoods(query: String, limit: Int = 20) async throws -> [Food] {
try await api.get(
"\(basePath)/foods/search",
queryItems: [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "limit", value: String(limit)),
]
)
}
func getRecentFoods(limit: Int = 8) async throws -> [FoodItem] {
try await api.get("/api/fitness/foods/recent?limit=\(limit)")
func getRecentFoods(limit: Int = 20) async throws -> [RecentFood] {
try await api.get(
"\(basePath)/foods/recent",
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
)
}
func getFood(id: String) async throws -> FoodItem {
try await api.get("/api/fitness/foods/\(id)")
func getFood(id: String) async throws -> Food {
try await api.get("\(basePath)/foods/\(id)")
}
func getGoals(date: String) async throws -> DailyGoal {
try await api.get("/api/fitness/goals/for-date?date=\(date)")
}
func updateGoals(_ req: UpdateGoalsRequest) async throws -> DailyGoal {
try await api.put("/api/fitness/goals", body: req)
}
// MARK: - Templates
func getTemplates() async throws -> [MealTemplate] {
try await api.get("/api/fitness/templates")
try await api.get("\(basePath)/templates")
}
func logTemplate(id: String, date: String) async throws {
struct Empty: Decodable {}
let _: Empty = try await api.post("/api/fitness/templates/\(id)/log?date=\(date)")
func logTemplate(id: String, mealType: String, entryDate: String) async throws -> TemplateLogResponse {
struct LogBody: Encodable {
let mealType: String
let entryDate: String
}
return try await api.post(
"\(basePath)/templates/\(id)/log",
body: LogBody(mealType: mealType, entryDate: entryDate)
)
}
// MARK: - Assistant (raw)
func sendAssistantMessage(data: Data) async throws -> Data {
try await api.rawPost(path: "/api/assistant/fitness", data: data)
}
}

View File

@@ -1,13 +1,270 @@
import Foundation
import SwiftUI
// MARK: - Meal Type
// MARK: - Flexible Number Decoding
enum MealType: String, Codable, CaseIterable, Identifiable {
/// Decodes a JSON value that may be Int, Double, or String into a Double
struct FlexibleDouble: Decodable {
let value: Double
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
value = d
} else if let i = try? container.decode(Int.self) {
value = Double(i)
} else if let s = try? container.decode(String.self), let d = Double(s) {
value = d
} else if container.decodeNil() {
value = 0
} else {
value = 0
}
}
init(_ v: Double) { value = v }
}
// MARK: - Food Entry (from GET /api/entries?date=...)
struct FoodEntry: Decodable, Identifiable {
let id: String
let userId: String?
let foodId: String?
let mealType: String
let entryDate: String
let entryType: String
let quantity: Double
let unit: String
let servingDescription: String?
let snapshotFoodName: String
let snapshotServingLabel: String?
let snapshotGrams: Double?
let snapshotCalories: Double
let snapshotProtein: Double
let snapshotCarbs: Double
let snapshotFat: Double
let snapshotSugar: Double
let snapshotFiber: Double
let source: String
let entryMethod: String
let rawText: String?
let confidenceScore: Double?
let note: String?
let imageRef: String?
let aiMetadata: String?
let idempotencyKey: String?
let createdAt: String?
let foodImagePath: String?
// Computed convenience properties
var foodName: String { snapshotFoodName }
var calories: Double { snapshotCalories }
var protein: Double { snapshotProtein }
var carbs: Double { snapshotCarbs }
var fat: Double { snapshotFat }
var sugar: Double { snapshotSugar }
var fiber: Double { snapshotFiber }
}
// MARK: - Daily Goal (from GET /api/goals/for-date?date=...)
struct DailyGoal: Decodable, Identifiable {
let id: String
let userId: String
let startDate: String
let endDate: String?
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double
let fiber: Double
let isActive: Int
let createdAt: String
}
// MARK: - Food Serving (nested in Food)
struct FoodServing: Decodable, Identifiable {
let id: String
let foodId: String
let name: String
let amountInBase: Double
let isDefault: Int
let createdAt: String
}
// MARK: - Food (from GET /api/foods, /api/foods/search)
struct Food: Decodable, Identifiable {
let id: String
let name: String
let normalizedName: String?
let brand: String?
let brandNormalized: String?
let barcode: String?
let notes: String?
let caloriesPerBase: Double
let proteinPerBase: Double
let carbsPerBase: Double
let fatPerBase: Double
let sugarPerBase: Double
let fiberPerBase: Double
let baseUnit: String
let status: String
let createdByUserId: String?
let isShared: Int?
let imagePath: String?
let createdAt: String?
let updatedAt: String?
let servings: [FoodServing]?
// Search-specific fields (only present in search results)
let score: Double?
let matchType: String?
}
// MARK: - Recent Food (from GET /api/foods/recent)
struct RecentFood: Decodable, Identifiable {
let foodId: String
let snapshotFoodName: String
let caloriesPerBase: Double
let proteinPerBase: Double
let carbsPerBase: Double
let fatPerBase: Double
let sugarPerBase: Double
let fiberPerBase: Double
let baseUnit: String
let lastUsed: String
var id: String { foodId }
var name: String { snapshotFoodName }
}
// MARK: - Meal Template (from GET /api/templates)
struct MealTemplate: Decodable, Identifiable {
let id: String
let userId: String
let name: String
let mealType: String?
let isFavorite: Int
let isArchived: Int
let createdAt: String
let updatedAt: String
let items: [MealTemplateItem]
}
struct MealTemplateItem: Decodable, Identifiable {
let id: String
let templateId: String
let foodId: String
let quantity: Double
let unit: String
let servingDescription: String?
let snapshotFoodName: String
let snapshotCalories: Double
let snapshotProtein: Double
let snapshotCarbs: Double
let snapshotFat: Double
let snapshotSugar: Double
let snapshotFiber: Double
let createdAt: String
var foodName: String { snapshotFoodName }
var calories: Double { snapshotCalories }
var protein: Double { snapshotProtein }
var carbs: Double { snapshotCarbs }
var fat: Double { snapshotFat }
}
// MARK: - Create Entry Request
struct CreateEntryRequest: Encodable {
let foodId: String
let quantity: Double
let unit: String
let mealType: String
let entryDate: String
let entryMethod: String
let source: String
let servingId: String?
init(
foodId: String,
quantity: Double = 1.0,
unit: String = "serving",
mealType: String = "snack",
entryDate: String,
entryMethod: String = "manual",
source: String = "ios",
servingId: String? = nil
) {
self.foodId = foodId
self.quantity = quantity
self.unit = unit
self.mealType = mealType
self.entryDate = entryDate
self.entryMethod = entryMethod
self.source = source
self.servingId = servingId
}
}
// MARK: - Delete Response
struct SuccessResponse: Decodable {
let success: Bool
}
// MARK: - Template Log Response
struct TemplateLogResponse: Decodable {
let logged: Int
let entries: [FoodEntry]
}
// MARK: - Fitness Draft (from assistant manual init from dictionary)
struct FitnessDraft: Identifiable {
let id = UUID()
let foodName: String
let mealType: String
let entryDate: String
let quantity: Double
let unit: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double
let fiber: Double
let note: String
let defaultServingLabel: String
init(from dict: [String: Any]) {
foodName = dict["food_name"] as? String ?? ""
mealType = dict["meal_type"] as? String ?? "snack"
entryDate = dict["entry_date"] as? String ?? ""
quantity = (dict["quantity"] as? Double) ?? (dict["quantity"] as? Int).map(Double.init) ?? 1.0
unit = dict["unit"] as? String ?? "serving"
calories = (dict["calories"] as? Double) ?? (dict["calories"] as? Int).map(Double.init) ?? 0
protein = (dict["protein"] as? Double) ?? (dict["protein"] as? Int).map(Double.init) ?? 0
carbs = (dict["carbs"] as? Double) ?? (dict["carbs"] as? Int).map(Double.init) ?? 0
fat = (dict["fat"] as? Double) ?? (dict["fat"] as? Int).map(Double.init) ?? 0
sugar = (dict["sugar"] as? Double) ?? (dict["sugar"] as? Int).map(Double.init) ?? 0
fiber = (dict["fiber"] as? Double) ?? (dict["fiber"] as? Int).map(Double.init) ?? 0
note = dict["note"] as? String ?? ""
defaultServingLabel = dict["default_serving_label"] as? String ?? ""
}
}
// MARK: - Meal Type Helpers
enum MealType: String, CaseIterable {
case breakfast, lunch, dinner, snack
var id: String { rawValue }
var displayName: String {
rawValue.capitalized
}
@@ -16,509 +273,17 @@ enum MealType: String, Codable, CaseIterable, Identifiable {
switch self {
case .breakfast: return "sunrise.fill"
case .lunch: return "sun.max.fill"
case .dinner: return "moon.fill"
case .dinner: return "moon.stars.fill"
case .snack: return "leaf.fill"
}
}
var capitalized: String {
rawValue.capitalized
}
/// Guess meal type based on current time of day
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..<17: return .snack
case 17..<22: return .dinner
default: return .snack
}
}
var color: Color {
var sortOrder: Int {
switch self {
case .breakfast: return .breakfastColor
case .lunch: return .lunchColor
case .dinner: return .dinnerColor
case .snack: return .snackColor
case .breakfast: return 0
case .lunch: return 1
case .dinner: return 2
case .snack: return 3
}
}
}
// MARK: - Food Entry
// API fields (snake_case) are auto-converted to camelCase by decoder.
// The API returns snapshot_food_name, snapshot_calories, etc. no top-level food_name/calories.
struct FoodEntry: Identifiable, Codable {
let id: String
let userId: String?
let foodId: String?
let mealType: MealType
let quantity: Double
let entryDate: String
let entryType: String?
let unit: String?
let servingDescription: String?
let snapshotFoodName: String?
let snapshotServingLabel: String?
let snapshotCalories: Double
let snapshotProtein: Double
let snapshotCarbs: Double
let snapshotFat: Double
let snapshotSugar: Double?
let snapshotFiber: Double?
let foodImagePath: String?
let note: String?
let entryMethod: String?
// Computed convenience accessors used by views
var foodName: String { snapshotFoodName ?? "Unknown" }
var calories: Double { snapshotCalories }
var protein: Double { snapshotProtein }
var carbs: Double { snapshotCarbs }
var fat: Double { snapshotFat }
var sugar: Double? { snapshotSugar }
var fiber: Double? { snapshotFiber }
var imageFilename: String? { foodImagePath }
var imageUrl: String? { foodImagePath }
var method: String? { entryMethod }
var loggedAt: String? { nil }
/// Convenience: raw string for the meal type (used by Color.mealColor(for:))
var mealTypeString: String { mealType.rawValue }
/// Fallback unit string for display
var unitLabel: String { unit ?? "serving" }
// No CodingKeys needed convertFromSnakeCase handles all mappings.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyCodingKey.self)
id = Self.decodeStringFlex(c, "id") ?? ""
userId = Self.decodeStringFlex(c, "userId")
foodId = Self.decodeStringFlex(c, "foodId")
mealType = (try? c.decode(MealType.self, forKey: AnyCodingKey("mealType"))) ?? .snack
quantity = Self.decodeDoubleFlex(c, "quantity") ?? 1.0
entryDate = (try? c.decode(String.self, forKey: AnyCodingKey("entryDate"))) ?? ""
entryType = try? c.decode(String.self, forKey: AnyCodingKey("entryType"))
unit = try? c.decode(String.self, forKey: AnyCodingKey("unit"))
servingDescription = try? c.decode(String.self, forKey: AnyCodingKey("servingDescription"))
snapshotFoodName = try? c.decode(String.self, forKey: AnyCodingKey("snapshotFoodName"))
snapshotServingLabel = try? c.decode(String.self, forKey: AnyCodingKey("snapshotServingLabel"))
snapshotCalories = Self.decodeDoubleFlex(c, "snapshotCalories") ?? 0
snapshotProtein = Self.decodeDoubleFlex(c, "snapshotProtein") ?? 0
snapshotCarbs = Self.decodeDoubleFlex(c, "snapshotCarbs") ?? 0
snapshotFat = Self.decodeDoubleFlex(c, "snapshotFat") ?? 0
snapshotSugar = Self.decodeDoubleFlex(c, "snapshotSugar")
snapshotFiber = Self.decodeDoubleFlex(c, "snapshotFiber")
foodImagePath = try? c.decode(String.self, forKey: AnyCodingKey("foodImagePath"))
note = try? c.decode(String.self, forKey: AnyCodingKey("note"))
entryMethod = try? c.decode(String.self, forKey: AnyCodingKey("entryMethod"))
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: AnyCodingKey.self)
try c.encode(id, forKey: AnyCodingKey("id"))
try c.encodeIfPresent(userId, forKey: AnyCodingKey("userId"))
try c.encodeIfPresent(foodId, forKey: AnyCodingKey("foodId"))
try c.encode(mealType, forKey: AnyCodingKey("mealType"))
try c.encode(quantity, forKey: AnyCodingKey("quantity"))
try c.encode(entryDate, forKey: AnyCodingKey("entryDate"))
try c.encodeIfPresent(snapshotFoodName, forKey: AnyCodingKey("snapshotFoodName"))
try c.encode(snapshotCalories, forKey: AnyCodingKey("snapshotCalories"))
try c.encode(snapshotProtein, forKey: AnyCodingKey("snapshotProtein"))
try c.encode(snapshotCarbs, forKey: AnyCodingKey("snapshotCarbs"))
try c.encode(snapshotFat, forKey: AnyCodingKey("snapshotFat"))
try c.encodeIfPresent(snapshotSugar, forKey: AnyCodingKey("snapshotSugar"))
try c.encodeIfPresent(snapshotFiber, forKey: AnyCodingKey("snapshotFiber"))
try c.encodeIfPresent(foodImagePath, forKey: AnyCodingKey("foodImagePath"))
}
// Flexible decoding helpers
private static func decodeDoubleFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
let k = AnyCodingKey(key)
if let v = try? c.decode(Double.self, forKey: k) { return v }
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
return nil
}
private static func decodeStringFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> String? {
let k = AnyCodingKey(key)
if let v = try? c.decode(String.self, forKey: k) { return v }
if let v = try? c.decode(Int.self, forKey: k) { return String(v) }
if let v = try? c.decode(Double.self, forKey: k) { return String(Int(v)) }
return nil
}
}
// MARK: - Food Item
// API: id (UUID string), name, brand, base_unit, calories_per_base, protein_per_base, etc.
struct FoodItem: Identifiable, Codable {
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 imageFilename: String?
let favorite: Bool?
// Computed convenience accessors (used by views that reference .calories, .protein, etc.)
var calories: Double { caloriesPerBase }
var protein: Double { proteinPerBase }
var carbs: Double { carbsPerBase }
var fat: Double { fatPerBase }
var sugar: Double? { sugarPerBase }
var fiber: Double? { fiberPerBase }
var servingSize: String? { baseUnit }
var imageUrl: String? { imageFilename }
var displayUnit: String { baseUnit ?? "serving" }
var displayInfo: String {
let cal = Int(caloriesPerBase)
return "\(cal) cal per \(displayUnit)"
}
func scaledCalories(quantity: Double) -> Double { caloriesPerBase * quantity }
func scaledProtein(quantity: Double) -> Double { proteinPerBase * quantity }
func scaledCarbs(quantity: Double) -> Double { carbsPerBase * quantity }
func scaledFat(quantity: Double) -> Double { fatPerBase * quantity }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyCodingKey.self)
// id can be String or Int
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
id = v
} else if let v = try? c.decode(Int.self, forKey: AnyCodingKey("id")) {
id = String(v)
} else {
id = ""
}
name = (try? c.decode(String.self, forKey: AnyCodingKey("name"))) ?? ""
brand = try? c.decode(String.self, forKey: AnyCodingKey("brand"))
baseUnit = try? c.decode(String.self, forKey: AnyCodingKey("baseUnit"))
caloriesPerBase = Self.doubleFlex(c, "caloriesPerBase")
proteinPerBase = Self.doubleFlex(c, "proteinPerBase")
carbsPerBase = Self.doubleFlex(c, "carbsPerBase")
fatPerBase = Self.doubleFlex(c, "fatPerBase")
sugarPerBase = Self.doubleFlexOpt(c, "sugarPerBase")
fiberPerBase = Self.doubleFlexOpt(c, "fiberPerBase")
status = try? c.decode(String.self, forKey: AnyCodingKey("status"))
imageFilename = try? c.decode(String.self, forKey: AnyCodingKey("imageFilename"))
favorite = try? c.decode(Bool.self, forKey: AnyCodingKey("favorite"))
}
private static func doubleFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double {
let k = AnyCodingKey(key)
if let v = try? c.decode(Double.self, forKey: k) { return v }
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
return 0
}
private static func doubleFlexOpt(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
let k = AnyCodingKey(key)
if let v = try? c.decode(Double.self, forKey: k) { return v }
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
return nil
}
}
// MARK: - Daily Goal
// API: id (UUID), calories, protein, carbs, fat, sugar, fiber, is_active
struct DailyGoal: Codable {
let id: String?
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
let isActive: Int?
static let defaultGoal = DailyGoal()
init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
self.id = nil
self.calories = calories
self.protein = protein
self.carbs = carbs
self.fat = fat
self.sugar = sugar
self.fiber = fiber
self.isActive = nil
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyCodingKey.self)
id = try? c.decode(String.self, forKey: AnyCodingKey("id"))
calories = Self.doubleFlex(c, "calories", default: 2000)
protein = Self.doubleFlex(c, "protein", default: 150)
carbs = Self.doubleFlex(c, "carbs", default: 250)
fat = Self.doubleFlex(c, "fat", default: 65)
sugar = Self.doubleFlexOpt(c, "sugar")
fiber = Self.doubleFlexOpt(c, "fiber")
if let v = try? c.decode(Int.self, forKey: AnyCodingKey("isActive")) {
isActive = v
} else if let v = try? c.decode(Double.self, forKey: AnyCodingKey("isActive")) {
isActive = Int(v)
} else {
isActive = nil
}
}
private static func doubleFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String, default def: Double) -> Double {
let k = AnyCodingKey(key)
if let v = try? c.decode(Double.self, forKey: k) { return v }
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
return def
}
private static func doubleFlexOpt(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
let k = AnyCodingKey(key)
if let v = try? c.decode(Double.self, forKey: k) { return v }
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
return nil
}
}
// MARK: - Meal Template
// API: id (UUID), name, meal_type, total_calories, total_protein, total_carbs, total_fat, item_count
struct MealTemplate: Identifiable, Codable {
let id: String
let name: String
let mealType: MealType
let totalCalories: Double?
let totalProtein: Double?
let totalCarbs: Double?
let totalFat: Double?
let itemCount: Int?
// Convenience accessors used by views
var calories: Double { totalCalories ?? 0 }
var protein: Double? { totalProtein }
var carbs: Double? { totalCarbs }
var fat: Double? { totalFat }
var itemsCount: Int? { itemCount }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyCodingKey.self)
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
id = v
} else if let v = try? c.decode(Int.self, forKey: AnyCodingKey("id")) {
id = String(v)
} else {
id = ""
}
name = (try? c.decode(String.self, forKey: AnyCodingKey("name"))) ?? ""
mealType = (try? c.decode(MealType.self, forKey: AnyCodingKey("mealType"))) ?? .snack
totalCalories = Self.doubleFlexOpt(c, "totalCalories")
totalProtein = Self.doubleFlexOpt(c, "totalProtein")
totalCarbs = Self.doubleFlexOpt(c, "totalCarbs")
totalFat = Self.doubleFlexOpt(c, "totalFat")
if let v = try? c.decode(Int.self, forKey: AnyCodingKey("itemCount")) {
itemCount = v
} else if let v = try? c.decode(Double.self, forKey: AnyCodingKey("itemCount")) {
itemCount = Int(v)
} else {
itemCount = nil
}
}
private static func doubleFlexOpt(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
let k = AnyCodingKey(key)
if let v = try? c.decode(Double.self, forKey: k) { return v }
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
return nil
}
}
// MARK: - Requests
// Note: APIClient uses encoder.keyEncodingStrategy = .convertToSnakeCase
// so camelCase properties are auto-converted to snake_case in JSON.
struct CreateEntryRequest: Encodable {
let foodId: String?
let quantity: Double
let unit: String?
let mealType: String
let entryDate: String
let entryMethod: String?
let source: String?
// Additional fields for manual entries without a foodId
let foodName: String?
let calories: Double?
let protein: Double?
let carbs: Double?
let fat: Double?
let sugar: Double?
let fiber: Double?
/// Convenience init for adding from food library (foodId-based)
init(foodId: String, quantity: Double, unit: String, mealType: String, entryDate: String, entryMethod: String? = "manual", source: String? = "ios_app") {
self.foodId = foodId
self.quantity = quantity
self.unit = unit
self.mealType = mealType
self.entryDate = entryDate
self.entryMethod = entryMethod
self.source = source
self.foodName = nil
self.calories = nil
self.protein = nil
self.carbs = nil
self.fat = nil
self.sugar = nil
self.fiber = nil
}
/// Convenience init for manual entries (with inline macros)
init(foodId: String? = nil, foodName: String, mealType: String, quantity: Double, entryDate: String, calories: Double, protein: Double, carbs: Double, fat: Double, sugar: Double? = nil, fiber: Double? = nil) {
self.foodId = foodId
self.foodName = foodName
self.mealType = mealType
self.quantity = quantity
self.entryDate = entryDate
self.calories = calories
self.protein = protein
self.carbs = carbs
self.fat = fat
self.sugar = sugar
self.fiber = fiber
self.unit = nil
self.entryMethod = "manual"
self.source = "ios_app"
}
}
struct UpdateEntryRequest: Encodable {
let quantity: Double
}
struct UpdateGoalsRequest: Encodable {
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double
let fiber: Double
}
// MARK: - Fitness Draft (AI Chat)
struct FitnessDraft {
let foodName: String
let mealType: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
let quantity: Double
init?(from dict: [String: Any]) {
guard let name = dict["food_name"] as? String else { return nil }
foodName = name
mealType = (dict["meal_type"] as? String) ?? "snack"
calories = Self.flexDouble(dict["calories"])
protein = Self.flexDouble(dict["protein"])
carbs = Self.flexDouble(dict["carbs"])
fat = Self.flexDouble(dict["fat"])
sugar = dict["sugar"].flatMap { Self.flexDoubleOpt($0) }
fiber = dict["fiber"].flatMap { Self.flexDoubleOpt($0) }
quantity = Self.flexDouble(dict["quantity"], default: 1)
}
private static func flexDouble(_ val: Any?, default def: Double = 0) -> Double {
if let v = val as? Double { return v }
if let v = val as? Int { return Double(v) }
if let v = val as? NSNumber { return v.doubleValue }
return def
}
private static func flexDoubleOpt(_ val: Any) -> Double? {
if let v = val as? Double { return v }
if let v = val as? Int { return Double(v) }
if let v = val as? NSNumber { return v.doubleValue }
return nil
}
}
// MARK: - Source Link
struct SourceLink: Identifiable {
let id: String
let title: String
let type: String
let href: String
init?(from dict: [String: Any]) {
guard let id = dict["id"] as? String,
let title = dict["title"] as? String else { return nil }
self.id = id
self.title = title
self.type = (dict["type"] as? String) ?? ""
self.href = (dict["href"] as? String) ?? ""
}
}
// MARK: - AnyCodingKey (flexible key lookup)
struct AnyCodingKey: 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 = String(intValue)
self.intValue = intValue
}
}
// MARK: - Meal Group (used by TodayView)
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 }
}
}

View File

@@ -4,128 +4,78 @@ import Foundation
final class FitnessRepository {
static let shared = FitnessRepository()
private let api = FitnessAPI()
var entries: [FoodEntry] = []
var goal: DailyGoal?
var foods: [Food] = []
var recentFoods: [RecentFood] = []
var templates: [MealTemplate] = []
var isLoading = false
var error: String?
private let api = FitnessAPI()
// Caches
private var entriesCache: [String: [FoodEntry]] = [:]
private var goalsCache: [String: DailyGoal] = [:]
private var recentFoodsCache: [FoodItem]?
private var templatesCache: [MealTemplate]?
// MARK: - Entries
func entries(for date: String, forceRefresh: Bool = false) async throws -> [FoodEntry] {
if !forceRefresh, let cached = entriesCache[date] {
return cached
func loadEntries(date: String) async {
do {
entries = try await api.getEntries(date: date)
} catch {
self.error = error.localizedDescription
}
let result = try await api.getEntries(date: date)
entriesCache[date] = result
return result
}
// MARK: - Goals
func goals(for date: String) async throws -> DailyGoal {
if let cached = goalsCache[date] {
return cached
func loadGoal(date: String) async {
do {
goal = try await api.getGoalsForDate(date: date)
} catch {
goal = nil
}
let result = try await api.getGoals(date: date)
goalsCache[date] = result
return result
}
// MARK: - Create / Update / Delete Entries
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
let entry = try await api.createEntry(req)
// Invalidate cache for the entry date
entriesCache.removeValue(forKey: entry.entryDate)
func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry {
let entry = try await api.createEntry(request)
await loadEntries(date: request.entryDate)
return entry
}
/// Alias kept for backward compatibility
func addEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
try await createEntry(req)
}
func updateEntry(id: String, request: UpdateEntryRequest, date: String) async throws -> FoodEntry {
let updated = try await api.updateEntry(id: id, quantity: request.quantity)
entriesCache.removeValue(forKey: date)
return updated
}
func deleteEntry(id: String, date: String) async throws {
try await api.deleteEntry(id: id)
entriesCache[date]?.removeAll { $0.id == id }
_ = try await api.deleteEntry(id: id)
await loadEntries(date: date)
}
// MARK: - Food Search
// MARK: - Foods
func searchFoods(query: String) async throws -> [FoodItem] {
try await api.searchFoods(query: query)
}
func recentFoods(forceRefresh: Bool = false) async throws -> [FoodItem] {
if !forceRefresh, let cached = recentFoodsCache {
return cached
func loadFoods(limit: Int = 100) async {
do {
foods = try await api.getFoods(limit: limit)
} catch {
self.error = error.localizedDescription
}
}
func searchFoods(query: String, limit: Int = 20) async throws -> [Food] {
try await api.searchFoods(query: query, limit: limit)
}
func loadRecentFoods(limit: Int = 20) async {
do {
recentFoods = try await api.getRecentFoods(limit: limit)
} catch {
self.error = error.localizedDescription
}
let result = try await api.getRecentFoods()
recentFoodsCache = result
return result
}
// MARK: - Templates
func templates(forceRefresh: Bool = false) async throws -> [MealTemplate] {
if !forceRefresh, let cached = templatesCache {
return cached
}
let result = try await api.getTemplates()
templatesCache = result
return result
}
func logTemplate(id: String, date: String) async throws {
try await api.logTemplate(id: id, date: date)
// Invalidate entries cache for that date so it reloads
entriesCache.removeValue(forKey: date)
}
// MARK: - Legacy loadDay (used by GoalsViewModel)
/// Kept for GoalsViewModel compatibility loads entries + goals into the old-style properties.
var entries: [FoodEntry] = []
var goals: DailyGoal = DailyGoal()
func loadDay(date: String) async {
isLoading = true
error = nil
func loadTemplates() async {
do {
async let e = api.getEntries(date: date)
async let g = api.getGoals(date: date)
entries = try await e
goals = try await g
templates = try await api.getTemplates()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
// MARK: - Computed Helpers (legacy)
var totalCalories: Double { entries.reduce(0) { $0 + $1.calories * $1.quantity } }
var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } }
var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } }
var totalFat: Double { entries.reduce(0) { $0 + $1.fat * $1.quantity } }
func entriesForMeal(_ meal: MealType) -> [FoodEntry] {
entries.filter { $0.mealType == meal }
}
func mealCalories(_ meal: MealType) -> Double {
entriesForMeal(meal).reduce(0) { $0 + $1.calories * $1.quantity }
func logTemplate(id: String, mealType: String, entryDate: String) async throws -> TemplateLogResponse {
try await api.logTemplate(id: id, mealType: mealType, entryDate: entryDate)
}
}

View File

@@ -1,108 +1,59 @@
import Foundation
@MainActor @Observable
@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
var searchText = ""
var searchResults: [Food] = []
var recentFoods: [RecentFood] = []
var allFoods: [Food] = []
var isSearching = false
var isLoadingInitial = false
var error: String?
private var searchTask: Task<Void, Never>?
var displayedFoods: [FoodItem] {
if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
return recentFoods
}
return searchResults
func loadInitial() async {
isLoadingInitial = true
async let recentTask: () = loadRecent()
async let allTask: () = loadAll()
_ = await (recentTask, allTask)
isLoadingInitial = false
}
var isShowingRecent: Bool {
searchText.trimmingCharacters(in: .whitespaces).isEmpty
private func loadRecent() async {
await repo.loadRecentFoods(limit: 20)
recentFoods = repo.recentFoods
}
func loadRecent() async {
isLoadingRecent = true
do {
recentFoods = try await repo.recentFoods(forceRefresh: true)
} catch {
// Silent failure for recent foods
}
isLoadingRecent = false
private func loadAll() async {
await repo.loadFoods(limit: 200)
allFoods = repo.foods
}
func search() {
let query = searchText.trimmingCharacters(in: .whitespaces)
// Cancel previous search
searchTask?.cancel()
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
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
let results = try await repo.searchFoods(query: query, limit: 20)
if !Task.isCancelled {
searchResults = results
}
} catch {
guard !Task.isCancelled else { return }
errorMessage = error.localizedDescription
if !Task.isCancelled {
self.error = 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
}
}

View File

@@ -1,20 +1,19 @@
import Foundation
@MainActor @Observable
@Observable
final class GoalsViewModel {
var goal: DailyGoal = DailyGoal()
var isLoading = true
var errorMessage: String?
private let api = FitnessAPI()
private let repo = FitnessRepository.shared
var goal: DailyGoal?
var isLoading = false
var error: String?
func load() async {
isLoading = true
errorMessage = nil
await repo.loadDay(date: Date().apiDateString)
goal = repo.goals
if let err = repo.error {
errorMessage = err
do {
goal = try await api.getGoalsForDate(date: Date().apiDateString)
} catch {
self.error = "No active goal found"
}
isLoading = false
}

View File

@@ -1,76 +0,0 @@
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
}
}

View File

@@ -1,45 +1,32 @@
import Foundation
@MainActor @Observable
@Observable
final class TemplatesViewModel {
var templates: [MealTemplate] = []
var isLoading = true
var errorMessage: String?
var isLogging = false
var loggedTemplateId: String?
private let repo = FitnessRepository.shared
var templates: [MealTemplate] = []
var isLoading = false
var error: String?
func load() async {
isLoading = true
errorMessage = nil
do {
templates = try await repo.templates(forceRefresh: true)
} catch {
errorMessage = error.localizedDescription
}
await repo.loadTemplates()
templates = repo.templates
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
func logTemplate(_ template: MealTemplate, mealType: String, entryDate: String) async throws -> TemplateLogResponse {
try await repo.logTemplate(id: template.id, mealType: mealType, entryDate: entryDate)
}
var groupedTemplates: [String: [MealTemplate]] {
Dictionary(grouping: templates, by: { $0.mealType.rawValue })
var groupedByMealType: [(String, [MealTemplate])] {
let grouped = Dictionary(grouping: templates) { t in
t.mealType ?? "other"
}
let order = ["breakfast", "lunch", "dinner", "snack", "other"]
return order.compactMap { key in
guard let items = grouped[key], !items.isEmpty else { return nil }
return (key.capitalized, items)
}
}
}

View File

@@ -1,31 +1,44 @@
import Foundation
@MainActor @Observable
@Observable
final class TodayViewModel {
var entries: [FoodEntry] = []
var goal: DailyGoal = DailyGoal()
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 selectedDate = Date()
var entries: [FoodEntry] = []
var goal: DailyGoal?
var isLoading = false
var error: String?
var dateString: String {
selectedDate.apiDateString
}
var mealGroups: [MealGroup] {
MealType.allCases.map { meal in
MealGroup(
meal: meal,
entries: entries.filter { $0.mealType == meal }
)
var isToday: Bool {
Calendar.current.isDateInToday(selectedDate)
}
var displayDateString: String {
if isToday { return "Today" }
if Calendar.current.isDateInYesterday(selectedDate) { return "Yesterday" }
if Calendar.current.isDateInTomorrow(selectedDate) { return "Tomorrow" }
return selectedDate.displayString
}
// MARK: - Grouped entries
var mealGroups: [(MealType, [FoodEntry])] {
let grouped = Dictionary(grouping: entries) { entry in
MealType(rawValue: entry.mealType) ?? .snack
}
return MealType.allCases.compactMap { meal in
guard let items = grouped[meal], !items.isEmpty else { return nil }
return (meal, items)
}
}
// MARK: - Totals
var totalCalories: Double {
entries.reduce(0) { $0 + $1.calories }
}
@@ -42,70 +55,61 @@ final class TodayViewModel {
entries.reduce(0) { $0 + $1.fat }
}
var caloriesRemaining: Double {
max(goal.calories - totalCalories, 0)
var calorieGoal: Double {
goal?.calories ?? 2000
}
var proteinGoal: Double {
goal?.protein ?? 150
}
var carbsGoal: Double {
goal?.carbs ?? 200
}
var fatGoal: Double {
goal?.fat ?? 65
}
// 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
}
error = nil
async let entriesTask: () = loadEntries()
async let goalTask: () = loadGoal()
_ = await (entriesTask, goalTask)
isLoading = false
}
func goToNextDay() {
selectedDate = selectedDate.adding(days: 1)
Task { await load() }
func loadEntries() async {
await repo.loadEntries(date: dateString)
entries = repo.entries
}
func loadGoal() async {
await repo.loadGoal(date: dateString)
goal = repo.goal
}
func deleteEntry(_ entry: FoodEntry) async {
do {
try await repo.deleteEntry(id: entry.id, date: dateString)
entries = repo.entries
} catch {
self.error = error.localizedDescription
}
}
func goToPreviousDay() {
selectedDate = selectedDate.adding(days: -1)
Task { await load() }
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
}
func goToNextDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
}
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"
}
}
}

View File

@@ -1,239 +1,243 @@
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"
let food: Food
@State private var quantity: Double = 1.0
@State private var selectedMeal: MealType = .snack
@State private var selectedServingId: String?
@State private var entryDate = Date()
@State private var isAdding = false
@State private var error: String?
private var defaultServing: FoodServing? {
food.servings?.first(where: { $0.isDefault == 1 }) ?? food.servings?.first
}
private var multiplier: Double {
if let servingId = selectedServingId,
let serving = food.servings?.first(where: { $0.id == servingId }) {
return quantity * serving.amountInBase
}
return quantity
}
private var previewCalories: Double { food.caloriesPerBase * multiplier }
private var previewProtein: Double { food.proteinPerBase * multiplier }
private var previewCarbs: Double { food.carbsPerBase * multiplier }
private var previewFat: Double { food.fatPerBase * multiplier }
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)
ScrollView {
VStack(spacing: 20) {
// Food name
VStack(spacing: 4) {
Text(food.name)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.textPrimary)
if let brand = food.brand, !brand.isEmpty {
Text(brand)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
Text("Add to \(mealType.displayName)")
.font(.body.weight(.semibold))
Text("Per \(food.baseUnit)")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.padding()
// Quantity
VStack(alignment: .leading, spacing: 8) {
Text("Quantity")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textSecondary)
HStack {
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.weight(.bold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
.frame(minWidth: 60)
Button {
quantity += 0.5
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentWarm)
}
}
.frame(maxWidth: .infinity)
// Serving picker
if let servings = food.servings, !servings.isEmpty {
Picker("Serving", selection: $selectedServingId) {
Text("Base (\(food.baseUnit))").tag(nil as String?)
ForEach(servings) { serving in
Text(serving.name).tag(serving.id as String?)
}
}
.pickerStyle(.menu)
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Meal picker
VStack(alignment: .leading, spacing: 8) {
Text("Meal")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textSecondary)
HStack(spacing: 8) {
ForEach(MealType.allCases, id: \.rawValue) { meal in
Button {
selectedMeal = meal
} label: {
VStack(spacing: 4) {
Image(systemName: meal.icon)
.font(.body)
Text(meal.displayName)
.font(.caption2.weight(.medium))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selectedMeal == meal
? Color.mealColor(for: meal.rawValue).opacity(0.15)
: Color.surfaceCard
)
.foregroundStyle(selectedMeal == meal
? Color.mealColor(for: meal.rawValue)
: Color.textSecondary
)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(selectedMeal == meal
? Color.mealColor(for: meal.rawValue).opacity(0.3)
: Color.clear, lineWidth: 1
)
)
}
}
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Nutrition preview
VStack(spacing: 8) {
Text("Nutrition Preview")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textSecondary)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
], spacing: 12) {
nutritionCell("Calories", value: previewCalories, unit: "kcal", color: .emerald)
nutritionCell("Protein", value: previewProtein, unit: "g", color: .macroProtein)
nutritionCell("Carbs", value: previewCarbs, unit: "g", color: .macroCarbs)
nutritionCell("Fat", value: previewFat, unit: "g", color: .macroFat)
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 12))
if let error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
// Add button
Button {
addEntry()
} label: {
if isAdding {
ProgressView()
.tint(.white)
} else {
Text("Add to \(selectedMeal.displayName)")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.accentWarm)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.clipShape(RoundedRectangle(cornerRadius: 12))
.disabled(isAdding)
}
.disabled(isAdding || quantity <= 0)
.padding()
}
.padding(20)
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundStyle(Color.text3)
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.onAppear {
quantityText = formatQuantity(quantity)
}
.onAppear {
if let ds = defaultServing {
selectedServingId = ds.id
}
}
}
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 {
private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 4) {
Text("\(Int(value))")
.font(.system(.title3, design: .rounded, weight: .bold))
.font(.headline.monospacedDigit())
.foregroundStyle(color)
Text(label)
Text("\(unit)")
.font(.caption2)
.foregroundStyle(Color.text3)
.foregroundStyle(Color.textTertiary)
Text(label)
.font(.caption2.weight(.medium))
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity)
}
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))"
private func addEntry() {
isAdding = true
error = nil
Task {
do {
let request = CreateEntryRequest(
foodId: food.id,
quantity: quantity,
unit: food.baseUnit,
mealType: selectedMeal.rawValue,
entryDate: entryDate.apiDateString,
entryMethod: "manual",
source: "ios",
servingId: selectedServingId
)
_ = try await FitnessRepository.shared.createEntry(request)
dismiss()
} catch {
self.error = error.localizedDescription
}
isAdding = false
}
return String(format: "%.1f", qty)
}
}

View File

@@ -3,256 +3,121 @@ 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))
}
@State private var showDeleteConfirmation = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
entryHeader
ScrollView {
VStack(spacing: 20) {
// Header
VStack(spacing: 6) {
Text(entry.foodName)
.font(.title2.weight(.bold))
.foregroundStyle(Color.textPrimary)
.multilineTextAlignment(.center)
// Quantity editor
quantityEditor
if let desc = entry.servingDescription {
Text(desc)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
// Macros grid
macrosGrid
HStack(spacing: 8) {
Image(systemName: (MealType(rawValue: entry.mealType) ?? .snack).icon)
.foregroundStyle(Color.mealColor(for: entry.mealType))
Text(entry.mealType.capitalized)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding()
// Details
detailsSection
// Nutrition grid
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
], spacing: 16) {
nutritionCell("Calories", value: entry.calories, unit: "kcal", color: .emerald)
nutritionCell("Protein", value: entry.protein, unit: "g", color: .macroProtein)
nutritionCell("Carbs", value: entry.carbs, unit: "g", color: .macroCarbs)
nutritionCell("Fat", value: entry.fat, unit: "g", color: .macroFat)
nutritionCell("Sugar", value: entry.sugar, unit: "g", color: .orange)
nutritionCell("Fiber", value: entry.fiber, unit: "g", color: .green)
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
// Delete button
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
HStack(spacing: 8) {
Image(systemName: "trash")
Text("Delete Entry")
}
.font(.body.weight(.medium))
.foregroundStyle(Color.error)
// Metadata
VStack(spacing: 8) {
metadataRow("Date", value: entry.entryDate)
metadataRow("Quantity", value: "\(String(format: "%.1f", entry.quantity)) \(entry.unit)")
metadataRow("Source", value: entry.source)
metadataRow("Method", value: entry.entryMethod)
if let note = entry.note, !note.isEmpty {
metadataRow("Note", value: note)
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
// Delete button
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete Entry", systemImage: "trash")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.error.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.frame(height: 48)
}
.padding(20)
.buttonStyle(.borderedProminent)
.tint(.red)
}
.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)\"?")
.padding()
}
.background(Color.canvas)
.navigationTitle("Entry Detail")
.navigationBarTitleDisplayMode(.inline)
.alert("Delete Entry?", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This will permanently remove \(entry.foodName) from your log.")
}
}
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 {
private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 4) {
Text("\(Int(value))")
.font(.title3.weight(.bold))
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(color)
Text(label)
Text(unit)
.font(.caption2)
.foregroundStyle(Color.text3)
.foregroundStyle(Color.textTertiary)
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.vertical, 8)
}
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.unitLabel)")
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 {
private func metadataRow(_ label: String, value: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(Color.text3)
.foregroundStyle(Color.textSecondary)
Spacer()
Text(value)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1)
.lineLimit(2)
.multilineTextAlignment(.trailing)
.foregroundStyle(Color.textPrimary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func formatQuantity(_ qty: Double) -> String {
if qty == qty.rounded() {
return "\(Int(qty))"
}
return String(format: "%.1f", qty)
}
}

View File

@@ -1,77 +1,55 @@
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 templates = "Templates"
case goals = "Goals"
case foods = "Foods"
}
@State private var selectedSubTab = 0
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Custom segmented control
tabBar
// Content
Group {
switch selectedTab {
case .today:
TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch)
case .templates:
TemplatesView(dateString: todayVM.dateString) {
Task { await todayVM.load() }
}
case .goals:
GoalsView()
case .foods:
FoodLibraryView(dateString: todayVM.dateString) {
Task { await todayVM.load() }
}
}
}
}
.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
VStack(spacing: 0) {
// Sub-tab selector
HStack(spacing: 0) {
ForEach(Array(fitnessSubTabs.enumerated()), id: \.offset) { index, tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
selectedSubTab = index
}
} label: {
Text(tab.rawValue)
.font(.subheadline.weight(selectedTab == tab ? .semibold : .medium))
.foregroundStyle(selectedTab == tab ? Color.surface : Color.text3)
Text(tab)
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
selectedTab == tab
? Color.accentWarm
: Color.surfaceSecondary
)
.clipShape(Capsule())
.background {
if selectedSubTab == index {
Capsule()
.fill(Color.accentWarm.opacity(0.12))
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.padding(.horizontal)
.padding(.top, 8)
// Content
TabView(selection: $selectedSubTab) {
TodayView()
.tag(0)
TemplatesView()
.tag(1)
GoalsView()
.tag(2)
FoodLibraryView()
.tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
.background(Color.canvas)
.navigationBarHidden(true)
}
}
private var fitnessSubTabs: [String] {
["Today", "Templates", "Goals", "Foods"]
}
}

View File

@@ -1,14 +1,96 @@
import SwiftUI
// FoodLibraryView is not currently used in the app navigation.
// Placeholder kept for future use.
struct FoodLibraryView: View {
let dateString: String
@State private var vm = FoodSearchViewModel()
@State private var selectedFood: Food?
var body: some View {
Text("Food Library")
.font(.headline)
.foregroundStyle(Color.text3)
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.textTertiary)
TextField("Search foods...", text: $vm.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if !vm.searchText.isEmpty {
Button {
vm.searchText = ""
vm.searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textTertiary)
}
}
}
.padding(12)
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.top, 8)
ScrollView {
LazyVStack(spacing: 0) {
let displayFoods = vm.searchText.isEmpty ? vm.allFoods : vm.searchResults
if vm.isLoadingInitial {
LoadingView()
} else if displayFoods.isEmpty {
EmptyStateView(
icon: "tray",
title: "No foods found",
subtitle: vm.searchText.isEmpty
? "Foods you add will appear here"
: "Try a different search term"
)
} else {
ForEach(displayFoods) { food in
Button {
selectedFood = food
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textPrimary)
.lineLimit(1)
HStack(spacing: 8) {
if let brand = food.brand, !brand.isEmpty {
Text(brand)
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
Text("\(Int(food.caloriesPerBase)) kcal")
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
Text("\(Int(food.proteinPerBase))p \(Int(food.carbsPerBase))c \(Int(food.fatPerBase))f")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
}
Divider().padding(.leading, 20)
}
}
}
}
}
.background(Color.canvas)
.task {
await vm.loadInitial()
}
.onChange(of: vm.searchText) {
vm.search()
}
.sheet(item: $selectedFood) { food in
AddFoodSheet(food: food)
}
}
}

View File

@@ -1,115 +1,93 @@
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 isSheet: Bool = false
@State private var vm = FoodSearchViewModel()
@State private var selectedFood: Food?
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
searchBar
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.textTertiary)
TextField("Search foods...", text: $vm.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if !vm.searchText.isEmpty {
Button {
vm.searchText = ""
vm.searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textTertiary)
}
}
}
.padding(12)
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.top, 8)
// Content
if viewModel.isSearching || viewModel.isLoadingRecent {
LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...")
} else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent {
if vm.isLoadingInitial {
LoadingView()
} else if !vm.searchText.isEmpty {
// Search results
searchResultsList
} else {
// Recent + All
defaultList
}
}
.background(Color.canvas)
.task {
await vm.loadInitial()
}
.onChange(of: vm.searchText) {
vm.search()
}
.sheet(item: $selectedFood) { food in
AddFoodSheet(food: food)
}
}
private var searchResultsList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if vm.isSearching {
ProgressView()
.padding()
} else if vm.searchResults.isEmpty {
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()
ForEach(vm.searchResults) { food in
foodRow(food)
}
.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 {
private var defaultList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty {
sectionHeader("Recent Foods")
LazyVStack(alignment: .leading, spacing: 0) {
if !vm.recentFoods.isEmpty {
sectionHeader("Recent")
ForEach(vm.recentFoods) { recent in
recentFoodRow(recent)
}
}
ForEach(viewModel.displayedFoods) { food in
FoodItemRow(food: food) {
viewModel.selectFood(food)
if !vm.allFoods.isEmpty {
sectionHeader("All Foods")
ForEach(vm.allFoods) { food in
foodRow(food)
}
Divider()
.padding(.leading, 60)
}
}
}
@@ -118,81 +96,65 @@ struct FoodSearchView: View {
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text4)
.foregroundStyle(Color.textTertiary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 8)
.padding(.bottom, 4)
}
}
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
private func foodRow(_ food: Food) -> some View {
Button {
selectedFood = food
} label: {
HStack {
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)
.foregroundStyle(Color.textPrimary)
.lineLimit(1)
if let brand = food.brand, !brand.isEmpty {
Text(brand)
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
}
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)
Text("\(Int(food.caloriesPerBase)) kcal/\(food.baseUnit)")
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
}
.padding(.horizontal, 16)
.padding(.horizontal, 20)
.padding(.vertical, 10)
}
}
private func recentFoodRow(_ recent: RecentFood) -> some View {
Button {
// Navigate to add sheet by loading the full food
Task {
do {
let food = try await FitnessAPI().getFood(id: recent.foodId)
selectedFood = food
} catch {
// Silently fail
}
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(recent.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textPrimary)
.lineLimit(1)
}
Spacer()
Text("\(Int(recent.caloriesPerBase)) kcal")
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

View File

@@ -1,139 +1,84 @@
import SwiftUI
struct GoalsView: View {
@State private var viewModel = GoalsViewModel()
@State private var vm = 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
VStack(spacing: 16) {
if vm.isLoading {
LoadingView()
} else if let goal = vm.goal {
goalCard(goal)
} else {
EmptyStateView(
icon: "target",
title: "No active goal",
subtitle: vm.error ?? "Set goals from the web app"
)
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)
Spacer(minLength: 80)
}
.padding(.horizontal)
.padding(.top, 8)
}
.background(Color.canvas)
.task {
await vm.load()
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
await vm.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)
private func goalCard(_ goal: DailyGoal) -> some View {
VStack(spacing: 16) {
Text("Daily Goals")
.font(.headline)
.foregroundStyle(Color.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
], spacing: 16) {
goalItem("Calories", value: goal.calories, unit: "kcal", color: .emerald)
goalItem("Protein", value: goal.protein, unit: "g", color: .macroProtein)
goalItem("Carbs", value: goal.carbs, unit: "g", color: .macroCarbs)
goalItem("Fat", value: goal.fat, unit: "g", color: .macroFat)
goalItem("Sugar", value: goal.sugar, unit: "g", color: .orange)
goalItem("Fiber", value: goal.fiber, unit: "g", color: .green)
}
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text2)
Text("Daily target")
HStack {
Text("Active since \(goal.startDate)")
.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)
.foregroundStyle(Color.textTertiary)
Spacer()
}
}
.padding(16)
.background(Color.surface)
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
}
private func goalItem(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 6) {
Text("\(Int(value))")
.font(.title2.weight(.bold).monospacedDigit())
.foregroundStyle(color)
Text("\(unit)")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}

View File

@@ -1,159 +0,0 @@
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)
}
}
}

View File

@@ -1,261 +1,112 @@
import SwiftUI
struct MealSectionView: View {
let group: MealGroup
let isExpanded: Bool
let onToggle: () -> Void
let mealType: MealType
let entries: [FoodEntry]
let onDelete: (FoodEntry) -> Void
let onAddFood: () -> Void
@State private var selectedEntry: FoodEntry?
@State private var isExpanded = true
private var mealColor: Color {
Color.mealColor(for: group.meal.rawValue)
private var totalCalories: Double {
entries.reduce(0) { $0 + $1.calories }
}
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)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 12) {
// Accent bar
RoundedRectangle(cornerRadius: 2)
.fill(Color.mealColor(for: mealType.rawValue))
.frame(width: 4, height: 32)
Text(group.meal.displayName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.text1)
Image(systemName: mealType.icon)
.font(.body.weight(.medium))
.foregroundStyle(Color.mealColor(for: mealType.rawValue))
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())
}
Text(mealType.displayName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Text("\(entries.count)")
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.textSecondary.opacity(0.1))
.clipShape(Capsule())
Spacer()
if !group.entries.isEmpty {
Text("\(Int(group.totalCalories)) kcal")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
}
Text("\(Int(totalCalories)) kcal")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textSecondary)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text4)
Image(systemName: "chevron.right")
.font(.caption.weight(.medium))
.foregroundStyle(Color.textTertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.padding(.vertical, 12)
}
.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)
ForEach(entries) { entry in
NavigationLink(destination: EntryDetailView(entry: entry, onDelete: {
onDelete(entry)
})) {
entryRow(entry)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
onDelete(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
.background(Color.surface)
.background(Color.surfaceCard)
.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])
}
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
.padding(.horizontal)
}
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) {
private func entryRow(_ entry: FoodEntry) -> some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(entry.foodName)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1)
.foregroundStyle(Color.textPrimary)
.lineLimit(1)
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)")
.font(.caption)
.foregroundStyle(Color.text3)
.lineLimit(1)
if let desc = entry.servingDescription ?? entry.snapshotServingLabel {
Text(desc)
.font(.caption)
.foregroundStyle(Color.textTertiary)
.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)
Text("\(Int(entry.calories)) kcal")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textSecondary)
HStack(spacing: 6) {
macroTag("P", value: entry.protein, color: .proteinColor)
macroTag("C", value: entry.carbs, color: .carbsColor)
macroTag("F", value: entry.fat, color: .fatColor)
}
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
}
.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)
.background(Color.surfaceCard)
}
}

View File

@@ -1,170 +1,125 @@
import SwiftUI
struct TemplatesView: View {
let dateString: String
let onTemplateLogged: () -> Void
@State private var viewModel = TemplatesViewModel()
@State private var vm = TemplatesViewModel()
@State private var confirmTemplate: MealTemplate?
@State private var logMealType: MealType = .snack
@State private var showConfirmation = false
@State private var logResult: String?
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)
VStack(spacing: 16) {
if vm.isLoading {
LoadingView()
} else if vm.templates.isEmpty {
EmptyStateView(
icon: "doc.text",
title: "No templates",
subtitle: "Create meal templates from the web app"
)
} else {
ForEach(vm.groupedByMealType, id: \.0) { group, templates in
VStack(alignment: .leading, spacing: 8) {
Text(group)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.textTertiary)
.textCase(.uppercase)
.padding(.horizontal, 4)
ForEach(templates) { template in
templateCard(template)
}
}
}
// Ungrouped
let ungrouped = viewModel.templates.filter { template in
!MealType.allCases.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)
Spacer(minLength: 80)
}
.padding(.horizontal)
.padding(.top, 8)
}
.background(Color.canvas)
.task {
await vm.load()
}
.refreshable {
await viewModel.load()
await vm.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)\"") {
.alert("Log Template", isPresented: $showConfirmation) {
Button("Log") {
guard let template = confirmTemplate else { return }
Task {
await viewModel.logTemplate(template, date: dateString) {
onTemplateLogged()
do {
let result = try await vm.logTemplate(
template,
mealType: logMealType.rawValue,
entryDate: Date().apiDateString
)
logResult = "Logged \(result.logged) items"
} catch {
logResult = error.localizedDescription
}
}
}
Button("Cancel", role: .cancel) {}
} message: { template in
Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).")
} message: {
if let template = confirmTemplate {
Text("Add \(template.items.count) items from \"\(template.name)\" to \(logMealType.displayName)?")
}
}
.alert("Result", isPresented: .init(
get: { logResult != nil },
set: { if !$0 { logResult = nil } }
)) {
Button("OK") { logResult = nil }
} message: {
Text(logResult ?? "")
}
}
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) {
private func templateCard(_ template: MealTemplate) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(template.name)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Spacer()
let totalCals = template.items.reduce(0.0) { $0 + $1.calories }
Text("\(Int(totalCals)) kcal")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1)
.lineLimit(1)
.foregroundStyle(Color.textSecondary)
}
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)
}
ForEach(template.items) { item in
HStack {
Text(item.foodName)
.font(.caption)
.foregroundStyle(Color.textSecondary)
Spacer()
Text("\(Int(item.calories)) kcal")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
}
Spacer()
// Log button
Button(action: onLog) {
if isLogging {
ProgressView()
.controlSize(.small)
.tint(Color.accentWarm)
} else {
Image(systemName: "plus.circle.fill")
.font(.title3)
HStack {
Spacer()
Button {
confirmTemplate = template
logMealType = MealType(rawValue: template.mealType ?? "snack") ?? .snack
showConfirmation = true
} label: {
Label("Log", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
}
}
.disabled(isLogging)
}
.padding(14)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.04), radius: 4, y: 2)
}
}

View File

@@ -1,171 +1,124 @@
import SwiftUI
struct TodayView: View {
@Bindable var viewModel: TodayViewModel
@Binding var showFoodSearch: Bool
@State private var vm = TodayViewModel()
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView {
VStack(spacing: 16) {
// Date selector
dateSelector
ScrollView {
VStack(spacing: 16) {
// Date selector
dateSelector
if viewModel.isLoading {
LoadingView(message: "Loading entries...")
.frame(height: 200)
} else {
// Macro summary card
macroSummaryCard
// Macro summary
macroSummary
// 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)
// Meal sections
if vm.entries.isEmpty && !vm.isLoading {
EmptyStateView(
icon: "fork.knife",
title: "No entries yet",
subtitle: "Tap + to log your first meal"
)
} else {
ForEach(vm.mealGroups, id: \.0) { mealType, entries in
MealSectionView(
mealType: mealType,
entries: entries,
onDelete: { entry in
Task { await vm.deleteEntry(entry) }
}
)
}
}
.padding(16)
}
.refreshable {
await viewModel.load()
}
// Floating add button
addButton
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.refreshable {
await vm.load()
}
.background(Color.canvas)
.task {
await viewModel.load()
await vm.load()
}
.onChange(of: vm.selectedDate) {
Task { await vm.load() }
}
}
// MARK: - Date Selector
private var dateSelector: some View {
HStack(spacing: 0) {
HStack {
Button {
viewModel.goToPreviousDay()
vm.goToPreviousDay()
} label: {
Image(systemName: "chevron.left")
.font(.body.weight(.semibold))
.font(.title3.weight(.medium))
.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()
vm.goToToday()
} label: {
Text(vm.displayDateString)
.font(.headline)
.foregroundStyle(Color.textPrimary)
}
Spacer()
Button {
vm.goToNextDay()
} label: {
Image(systemName: "chevron.right")
.font(.body.weight(.semibold))
.foregroundStyle(
viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm
)
.frame(width: 44, height: 44)
.font(.title3.weight(.medium))
.foregroundStyle(Color.accentWarm)
}
.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)
.padding(.horizontal, 20)
.padding(.vertical, 8)
}
// MARK: - Macro Summary
private var macroSummaryCard: some View {
private var macroSummary: some View {
VStack(spacing: 16) {
// Calories ring
HStack(spacing: 20) {
MacroRingLarge(
current: viewModel.totalCalories,
goal: viewModel.goal.calories,
color: .caloriesColor,
size: 100,
HStack(spacing: 24) {
MacroRingWithLabel(
consumed: vm.totalCalories,
goal: vm.calorieGoal,
label: "kcal",
color: .emerald,
size: 90,
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)
VStack(spacing: 10) {
MacroBar(
label: "Protein",
consumed: vm.totalProtein,
goal: vm.proteinGoal,
color: .macroProtein
)
MacroBar(
label: "Carbs",
consumed: vm.totalCarbs,
goal: vm.carbsGoal,
color: .macroCarbs
)
MacroBar(
label: "Fat",
consumed: vm.totalFat,
goal: vm.fatGoal,
color: .macroFat
)
}
.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)
.padding(16)
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
.padding(.horizontal)
}
}

View File

@@ -1,188 +1,122 @@
import SwiftUI
import PhotosUI
struct HomeView: View {
@Environment(AuthManager.self) private var authManager
@State private var viewModel = HomeViewModel()
@Environment(AuthManager.self) private var auth
@State private var vm = HomeViewModel()
@State private var showProfileMenu = false
var body: some View {
NavigationStack {
ZStack {
// Background
if let bg = vm.backgroundImage {
Image(uiImage: bg)
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea()
} else {
Color.canvas.ignoresSafeArea()
}
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()
VStack(spacing: 16) {
// Top bar
HStack {
Text("Home")
.font(.largeTitle.weight(.bold))
.foregroundStyle(vm.hasBackground ? .white : Color.textPrimary)
Spacer()
Menu {
PhotosPicker(
selection: $vm.selectedPhoto,
matching: .images
) {
Label("Change Background", systemImage: "photo")
}
if vm.hasBackground {
Button(role: .destructive) {
vm.removeBackground()
} label: {
Label("Remove Background", systemImage: "trash")
}
}
Divider()
Button(role: .destructive) {
Task { await auth.logout() }
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
Image(systemName: "person.circle.fill")
.font(.title2)
.foregroundStyle(vm.hasBackground ? .white : Color.accentWarm)
}
} label: {
Image(systemName: "person.circle.fill")
.font(.title3)
.foregroundStyle(Color.accentWarm)
}
.padding(.horizontal)
.padding(.top, 60)
// Widget grid
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
], spacing: 12) {
// Calorie widget
calorieWidget
.gridCellColumns(2)
}
.padding(.horizontal)
Spacer(minLength: 100)
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
.navigationBarHidden(true)
.task {
await vm.loadTodayData()
}
.onChange(of: vm.selectedPhoto) {
Task { await vm.handlePhotoSelection() }
}
}
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
private var calorieWidget: some View {
HStack(spacing: 20) {
MacroRingWithLabel(
consumed: vm.totalCalories,
goal: vm.calorieGoal,
label: "kcal",
color: .emerald,
size: 100,
lineWidth: 10
)
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")
VStack(alignment: .leading, spacing: 8) {
Text("Calories")
.font(.headline)
.foregroundStyle(vm.hasBackground ? .white : Color.textPrimary)
Text("\(Int(vm.totalCalories)) / \(Int(vm.calorieGoal))")
.font(.subheadline)
.foregroundStyle(vm.hasBackground ? .white.opacity(0.8) : Color.textSecondary)
let remaining = max(vm.calorieGoal - vm.totalCalories, 0)
Text("\(Int(remaining)) remaining")
.font(.caption)
.foregroundStyle(vm.hasBackground ? .white.opacity(0.6) : Color.textTertiary)
}
Spacer()
}
.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)
.background {
if vm.hasBackground {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(Color.surfaceCard)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
.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))
}
}

View File

@@ -1,49 +1,74 @@
import Foundation
import SwiftUI
import PhotosUI
@MainActor @Observable
@Observable
final class HomeViewModel {
var todayEntries: [FoodEntry] = []
var goal: DailyGoal = DailyGoal()
var isLoading = true
var errorMessage: String?
private let repo = FitnessRepository.shared
var totalCalories: Double {
todayEntries.reduce(0) { $0 + $1.calories }
var totalCalories: Double = 0
var calorieGoal: Double = 2000
var isLoading = false
// Background image
var backgroundImage: UIImage?
var selectedPhoto: PhotosPickerItem?
private let backgroundKey = "homeBackgroundImage"
init() {
loadSavedBackground()
}
var totalProtein: Double {
todayEntries.reduce(0) { $0 + $1.protein }
var hasBackground: Bool {
backgroundImage != nil
}
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 {
func loadTodayData() 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
}
async let entriesTask: () = repo.loadEntries(date: today)
async let goalTask: () = repo.loadGoal(date: today)
_ = await (entriesTask, goalTask)
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
calorieGoal = repo.goal?.calories ?? 2000
isLoading = false
}
// MARK: - Background Image
func handlePhotoSelection() async {
guard let item = selectedPhoto else { return }
guard let data = try? await item.loadTransferable(type: Data.self) else { return }
guard let original = UIImage(data: data) else { return }
let resized = resizeImage(original, maxWidth: 1200)
backgroundImage = resized
if let jpegData = resized.jpegData(compressionQuality: 0.8) {
UserDefaults.standard.set(jpegData, forKey: backgroundKey)
}
selectedPhoto = nil
}
func removeBackground() {
backgroundImage = nil
UserDefaults.standard.removeObject(forKey: backgroundKey)
}
private func loadSavedBackground() {
if let data = UserDefaults.standard.data(forKey: backgroundKey),
let image = UIImage(data: data) {
backgroundImage = image
}
}
private func resizeImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage {
let scale = maxWidth / image.size.width
guard scale < 1 else { return image }
let newSize = CGSize(width: maxWidth, height: image.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}

View File

@@ -4,45 +4,41 @@ struct LoadingView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 16) {
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
.tint(Color.accentWarm)
.controlSize(.regular)
Text(message)
.font(.subheadline)
.foregroundStyle(Color.text3)
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
}
}
struct ErrorBanner: View {
let message: String
var onRetry: (() -> Void)?
var retry: (() async -> Void)?
var body: some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Color.error)
.foregroundStyle(.orange)
Text(message)
.font(.subheadline)
.foregroundStyle(Color.text2)
.foregroundStyle(Color.textPrimary)
Spacer()
if let onRetry {
if let retry {
Button("Retry") {
onRetry()
Task { await retry() }
}
.font(.subheadline.weight(.semibold))
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
}
}
.padding(12)
.background(Color.error.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
}
}
@@ -55,18 +51,16 @@ struct EmptyStateView: View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundStyle(Color.text4)
.foregroundStyle(Color.textTertiary)
Text(title)
.font(.headline)
.foregroundStyle(Color.text2)
.foregroundStyle(Color.textPrimary)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(Color.text3)
.foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
}
.padding(40)
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
}

View File

@@ -2,74 +2,41 @@ import SwiftUI
struct MacroBar: View {
let label: String
let current: Double
let consumed: Double
let goal: Double
let color: Color
var showGrams: Bool = true
var color: Color = .emerald
var unit: String = "g"
private var progress: Double {
guard goal > 0 else { return 0 }
return min(current / goal, 1.0)
return min(max(consumed / goal, 0), 1.0)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(Color.text3)
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
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)
}
Text("\(Int(consumed))/\(Int(goal))\(unit)")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.textPrimary)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(color.opacity(0.12))
.frame(height: 6)
RoundedRectangle(cornerRadius: 4)
.fill(color.opacity(0.15))
.frame(height: 8)
Capsule()
RoundedRectangle(cornerRadius: 4)
.fill(color)
.frame(width: geo.size.width * progress, height: 6)
.animation(.easeOut(duration: 0.5), value: progress)
.frame(width: geo.size.width * progress, height: 8)
.animation(.easeInOut(duration: 0.5), value: progress)
}
}
.frame(height: 6)
.frame(height: 8)
}
}
}
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)
}
}

View File

@@ -1,77 +1,21 @@
import SwiftUI
struct MacroRing: View {
let current: Double
let consumed: 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
var color: Color = .emerald
var size: CGFloat = 100
private var progress: Double {
guard goal > 0 else { return 0 }
return min(current / goal, 1.0)
}
private var remaining: Double {
max(goal - current, 0)
return min(max(consumed / goal, 0), 1.0)
}
var body: some View {
ZStack {
Circle()
.stroke(color.opacity(0.12), lineWidth: lineWidth)
.stroke(color.opacity(0.15), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: progress)
@@ -80,17 +24,38 @@ struct MacroRingLarge: View {
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)
}
.animation(.easeInOut(duration: 0.6), value: progress)
}
.frame(width: size, height: size)
}
}
struct MacroRingWithLabel: View {
let consumed: Double
let goal: Double
let label: String
var color: Color = .emerald
var size: CGFloat = 100
var lineWidth: CGFloat = 10
var body: some View {
ZStack {
MacroRing(
consumed: consumed,
goal: goal,
lineWidth: lineWidth,
color: color,
size: size
)
VStack(spacing: 2) {
Text("\(Int(consumed))")
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
Text(label)
.font(.system(size: size * 0.1, weight: .medium))
.foregroundStyle(Color.textSecondary)
}
}
}
}

View File

@@ -1,98 +1,40 @@
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")
// MARK: - Canvas / Background
static let canvas = Color(red: 0.96, green: 0.94, blue: 0.90) // #F5EFE6
static let text1 = Color(hex: "18181B")
static let text2 = Color(hex: "3F3F46")
static let text3 = Color(hex: "71717A")
static let text4 = Color(hex: "A1A1AA")
// MARK: - Accent
static let accentWarm = Color(red: 0.545, green: 0.412, blue: 0.078) // #8B6914
static let emerald = Color(red: 0.020, green: 0.588, blue: 0.412) // #059669
// Accent warm amber/brown
static let accentWarm = Color(hex: "8B6914")
static let accentWarmBg = Color(hex: "FEF7E6")
// MARK: - Surfaces
static let surfaceCard = Color.white
static let surfaceSheet = Color(red: 0.98, green: 0.97, blue: 0.95)
// Emerald accent from web
static let accentEmerald = Color(hex: "059669")
static let accentEmeraldBg = Color(hex: "ECFDF5")
// MARK: - Text
static let textPrimary = Color(red: 0.12, green: 0.12, blue: 0.12)
static let textSecondary = Color(red: 0.45, green: 0.45, blue: 0.45)
static let textTertiary = Color(red: 0.65, green: 0.65, blue: 0.65)
// Semantic
static let success = Color(hex: "059669")
static let error = Color(hex: "DC2626")
static let warning = Color(hex: "D97706")
// MARK: - Meal Colors
static let mealBreakfast = Color(red: 1.0, green: 0.72, blue: 0.27) // warm orange
static let mealLunch = Color(red: 0.30, green: 0.69, blue: 0.31) // green
static let mealDinner = Color(red: 0.40, green: 0.35, blue: 0.80) // purple
static let mealSnack = Color(red: 0.93, green: 0.46, blue: 0.46) // coral
// 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")
// MARK: - Macros
static let macroProtein = Color(red: 0.35, green: 0.56, blue: 0.91) // blue
static let macroCarbs = Color(red: 0.96, green: 0.65, blue: 0.14) // amber
static let macroFat = Color(red: 0.85, green: 0.35, blue: 0.45) // pink-red
// 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")
static let breakfastColor = Color(hex: "F59E0B")
static let lunchColor = Color(hex: "059669")
static let dinnerColor = Color(hex: "3B82F6")
static let snackColor = 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)
static func mealColor(for mealType: String) -> Color {
switch mealType.lowercased() {
case "breakfast": return .mealBreakfast
case "lunch": return .mealLunch
case "dinner": return .mealDinner
case "snack": return .mealSnack
default: return .accentWarm
}
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 mealColor(for meal: MealType) -> Color {
mealColor(for: meal.rawValue)
}
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"
}
}
static func mealIcon(for meal: MealType) -> String {
mealIcon(for: meal.rawValue)
}
}

View File

@@ -1,58 +1,28 @@
import Foundation
extension Date {
/// Format as yyyy-MM-dd for API calls
private static let apiFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static let displayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE, MMM d"
return f
}()
var apiDateString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.string(from: self)
Self.apiFormatter.string(from: self)
}
/// Display format: "Mon, Apr 2"
var displayString: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d"
return formatter.string(from: self)
Self.displayFormatter.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
static func fromAPI(_ string: String) -> Date? {
apiFormatter.date(from: string)
}
}