feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
All checks were successful
Security Checks / dependency-audit (push) Successful in 1m13s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s

Brain Service:
- Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API)
- AI classification with tag definitions and folder assignment
- YouTube video download via yt-dlp
- Karakeep migration complete (96 items)
- Taxonomy management (folders with icons/colors, tags)
- Discovery shuffle, sort options, search (Meilisearch + pgvector)
- Item tag/folder editing, card color accents

RSS Reader Service:
- Custom FastAPI reader replacing Miniflux
- Feed management (add/delete/refresh), category support
- Full article extraction via Readability
- Background content fetching for new entries
- Mark all read with confirmation
- Infinite scroll, retention cleanup (30/60 day)
- 17 feeds migrated from Miniflux

iOS App (SwiftUI):
- Native iOS 17+ app with @Observable architecture
- Cookie-based auth, configurable gateway URL
- Dashboard with custom background photo + frosted glass widgets
- Full fitness module (today/templates/goals/food library)
- AI assistant chat (fitness + brain, raw JSON state management)
- 120fps ProMotion support

AI Assistants (Gateway):
- Unified dispatcher with fitness/brain domain detection
- Fitness: natural language food logging, photo analysis, multi-item splitting
- Brain: save/append/update/delete notes, search & answer, undo support
- Madiha user gets fitness-only (brain disabled)

Firefox Extension:
- One-click save to Brain from any page
- Login with platform credentials
- Right-click context menu (save page/link/image)
- Notes field for URL saves
- Signed and published on AMO

Other:
- Reader bookmark button routes to Brain (was Karakeep)
- Fitness food library with "Add" button + add-to-meal popup
- Kindle send file size check (25MB SMTP2GO limit)
- Atelier UI as default (useAtelierShell=true)
- Mobile upload box in nav drawer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 00:56:29 -05:00
parent af1765bd8e
commit 4592e35732
97 changed files with 11009 additions and 532 deletions

View File

@@ -0,0 +1,467 @@
// !$*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

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

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.078",
"green" : "0.412",
"red" : "0.545"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,5 @@
import Foundation
enum Config {
static let gatewayURL = "https://dash.quadjourney.com"
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct ContentView: View {
@Environment(AuthManager.self) private var authManager
var body: some View {
Group {
if authManager.isCheckingAuth {
ProgressView("Loading...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
} else if authManager.isLoggedIn {
MainTabView()
} else {
LoginView()
}
}
.task {
await authManager.checkAuth()
}
}
}
struct MainTabView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
FitnessTabView()
.tabItem {
Label("Fitness", systemImage: "figure.run")
}
.tag(1)
}
.tint(.accent)
}
}

View File

@@ -0,0 +1,145 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case httpError(Int, String?)
case decodingError(Error)
case networkError(Error)
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .httpError(let code, let msg): return msg ?? "HTTP error \(code)"
case .decodingError(let err): return "Decoding error: \(err.localizedDescription)"
case .networkError(let err): return err.localizedDescription
case .unknown(let msg): return msg
}
}
}
@Observable
final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.httpCookieStorage = .shared
session = URLSession(configuration: config)
decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
private func buildURL(_ path: String) throws -> URL {
guard let url = URL(string: "\(Config.gatewayURL)\(path)") else {
throw APIError.invalidURL
}
return url
}
func request<T: Decodable>(_ method: String, _ path: String, body: Encodable? = nil) async throws -> T {
let url = try buildURL(path)
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Accept")
if let body = body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
req.httpBody = try encoder.encode(body)
}
let (data, response): (Data, URLResponse)
do {
(data, 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)
}
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 let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let bodyStr = String(data: data, encoding: .utf8)
throw APIError.httpError(httpResponse.statusCode, bodyStr)
}
}
func rawPost(_ path: String, body: Data) async throws -> (Data, URLResponse) {
let url = try buildURL(path)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = body
let (data, response): (Data, URLResponse)
do {
(data, 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)
}
return (data, response)
}
func clearCookies() {
if let cookies = HTTPCookieStorage.shared.cookies {
for cookie in cookies {
HTTPCookieStorage.shared.deleteCookie(cookie)
}
}
}
}

View File

@@ -0,0 +1,104 @@
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?
private let api = APIClient.shared
private let loggedInKey = "isLoggedIn"
init() {
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
}
func checkAuth() async {
guard UserDefaults.standard.bool(forKey: loggedInKey) else {
isCheckingAuth = false
isLoggedIn = false
return
}
do {
let response: MeResponse = try await api.get("/api/auth/me")
if response.authenticated {
user = response.user
isLoggedIn = true
} else {
isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey)
}
} catch {
isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey)
}
isCheckingAuth = false
}
func login(username: String, password: String) async {
loginError = nil
do {
let response: LoginResponse = try await api.post("/api/auth/login", body: LoginRequest(username: username, password: password))
if response.success {
user = response.user
isLoggedIn = true
UserDefaults.standard.set(true, forKey: loggedInKey)
} else {
loginError = "Invalid credentials"
}
} catch let error as APIError {
loginError = error.localizedDescription
} catch {
loginError = error.localizedDescription
}
}
func logout() {
api.clearCookies()
isLoggedIn = false
user = nil
UserDefaults.standard.set(false, forKey: loggedInKey)
}
}

View File

@@ -0,0 +1,285 @@
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))
}
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.accent : Color.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selectedTab == tab ? Color.accent.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) {
ForEach(vm.messages) { message in
messageBubble(message)
.id(message.id)
}
if vm.isLoading {
HStack {
ProgressView()
.tint(Color.accent)
Text("Thinking...")
.font(.caption)
.foregroundStyle(Color.textSecondary)
Spacer()
}
.padding(.horizontal, 16)
.id("loading")
}
}
.padding(.vertical, 12)
}
.onAppear { scrollProxy = proxy }
.onChange(of: vm.messages.count) { _, _ in
withAnimation {
proxy.scrollTo(vm.messages.last?.id, anchor: .bottom)
}
}
}
if let error = vm.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal, 16)
.padding(.bottom, 4)
}
// 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")
.font(.title3)
.foregroundStyle(vm.photoData != nil ? Color.accent : Color.textSecondary)
}
TextField("Ask anything...", text: $vm.inputText)
.textFieldStyle(.plain)
.padding(10)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 20))
Button {
Task {
await vm.send()
withAnimation {
scrollProxy?.scrollTo(vm.messages.last?.id, anchor: .bottom)
}
}
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accent)
}
.disabled(vm.inputText.isEmpty && vm.photoData == nil)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.surface)
}
}
@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)
}
// Draft cards
ForEach(Array(message.drafts.enumerated()), id: \.offset) { _, draft in
draftCard(draft, applied: message.applied)
}
// 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)
}
}
private func draftCard(_ draft: FitnessDraft, applied: Bool) -> 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())
.foregroundStyle(Color.accent)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.accent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
HStack(spacing: 12) {
macroChip("Cal", Int(draft.calories))
macroChip("P", Int(draft.protein))
macroChip("C", Int(draft.carbs))
macroChip("F", 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)
}
}
}
.padding(12)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
.frame(maxWidth: 300, alignment: .leading)
}
private func macroChip(_ label: String, _ value: Int) -> some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.caption.bold())
.foregroundStyle(Color.textPrimary)
Text(label)
.font(.system(size: 9))
.foregroundStyle(Color.textSecondary)
}
}
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.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.accent.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}

View File

@@ -0,0 +1,150 @@
import SwiftUI
import PhotosUI
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
}
@Observable
final class AssistantViewModel {
var messages: [ChatMessage] = []
var inputText = ""
var isLoading = false
var error: String?
var selectedPhoto: PhotosPickerItem?
var photoData: Data?
// Raw JSON state from server never decode this
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"
}
func send(action: String = "chat") async {
let text = inputText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty || action == "apply" else { return }
if action == "chat" && !text.isEmpty {
messages.append(ChatMessage(role: "user", content: text))
inputText = ""
}
isLoading = true
error = nil
do {
// Build request as raw JSON
var requestDict: [String: Any] = [
"entryDate": entryDate,
"action": action,
"allowBrain": allowBrain
]
// 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()
}
let bodyData = try JSONSerialization.data(withJSONObject: requestDict)
let (responseData, _) = try await api.rawPost("/api/assistant/chat", body: bodyData)
// Parse response as raw JSON
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
error = "Invalid response"
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 draftsArray = json["drafts"] as? [[String: Any]] {
for dict in draftsArray {
if let d = FitnessDraft(from: dict) {
drafts.append(d)
}
}
}
// 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 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
))
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func applyDraft() async {
await send(action: "apply")
}
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
}
}
}
}

View File

@@ -0,0 +1,80 @@
import SwiftUI
struct LoginView: View {
@Environment(AuthManager.self) private var authManager
@State private var username = ""
@State private var password = ""
@State private var isLoading = false
var body: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 8) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 48))
.foregroundStyle(Color.accent)
Text("Platform")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
Text("Sign in to continue")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
VStack(spacing: 16) {
TextField("Username", text: $username)
.textFieldStyle(.plain)
.padding(14)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Password", text: $password)
.textFieldStyle(.plain)
.padding(14)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.textContentType(.password)
}
.padding(.horizontal, 32)
if let error = authManager.loginError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
Button {
isLoading = true
Task {
await authManager.login(username: username, password: password)
isLoading = false
}
} label: {
Group {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text("Sign In")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 32)
.disabled(username.isEmpty || password.isEmpty || isLoading)
Spacer()
Spacer()
}
.background(Color.canvas.ignoresSafeArea())
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
struct FitnessAPI {
private let api = APIClient.shared
func getEntries(date: String) async throws -> [FoodEntry] {
try await api.get("/api/fitness/entries?date=\(date)")
}
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
try await api.post("/api/fitness/entries", body: req)
}
func updateEntry(id: Int, 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: Int) async throws {
try await api.delete("/api/fitness/entries/\(id)")
}
func getFoods(limit: Int = 100) async throws -> [FoodItem] {
try await api.get("/api/fitness/foods?limit=\(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 getRecentFoods(limit: Int = 8) async throws -> [FoodItem] {
try await api.get("/api/fitness/foods/recent?limit=\(limit)")
}
func getFood(id: Int) async throws -> FoodItem {
try await api.get("/api/fitness/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)
}
func getTemplates() async throws -> [MealTemplate] {
try await api.get("/api/fitness/templates")
}
func logTemplate(id: Int, date: String) async throws {
struct Empty: Decodable {}
let _: Empty = try await api.post("/api/fitness/templates/\(id)/log?date=\(date)")
}
}

View File

@@ -0,0 +1,328 @@
import Foundation
import SwiftUI
// MARK: - Meal Type
enum MealType: String, Codable, CaseIterable, Identifiable {
case breakfast, lunch, dinner, snack
var id: String { rawValue }
var displayName: String {
rawValue.capitalized
}
var icon: String {
switch self {
case .breakfast: return "sunrise.fill"
case .lunch: return "sun.max.fill"
case .dinner: return "moon.fill"
case .snack: return "leaf.fill"
}
}
var color: Color {
switch self {
case .breakfast: return .breakfastColor
case .lunch: return .lunchColor
case .dinner: return .dinnerColor
case .snack: return .snackColor
}
}
}
// MARK: - Food Entry
struct FoodEntry: Identifiable, Codable {
let id: Int
let userId: Int?
let foodId: Int?
let mealType: MealType
let quantity: Double
let entryDate: String
let foodName: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
let servingSize: String?
let imageFilename: String?
enum CodingKeys: String, CodingKey {
case id
case userId = "user_id"
case foodId = "food_id"
case mealType = "meal_type"
case quantity
case entryDate = "entry_date"
case foodName = "food_name"
case snapshotFoodName = "snapshot_food_name"
case calories, protein, carbs, fat, sugar, fiber
case servingSize = "serving_size"
case imageFilename = "image_filename"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try Self.decodeIntFlex(c, .id)
userId = try? Self.decodeIntFlex(c, .userId)
foodId = try? Self.decodeIntFlex(c, .foodId)
mealType = try c.decode(MealType.self, forKey: .mealType)
quantity = try Self.decodeDoubleFlex(c, .quantity)
entryDate = try c.decode(String.self, forKey: .entryDate)
// Handle food_name or snapshot_food_name
if let name = try? c.decode(String.self, forKey: .foodName) {
foodName = name
} else if let name = try? c.decode(String.self, forKey: .snapshotFoodName) {
foodName = name
} else {
foodName = "Unknown"
}
calories = try Self.decodeDoubleFlex(c, .calories)
protein = try Self.decodeDoubleFlex(c, .protein)
carbs = try Self.decodeDoubleFlex(c, .carbs)
fat = try Self.decodeDoubleFlex(c, .fat)
sugar = try? Self.decodeDoubleFlex(c, .sugar)
fiber = try? Self.decodeDoubleFlex(c, .fiber)
servingSize = try? c.decode(String.self, forKey: .servingSize)
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encodeIfPresent(userId, forKey: .userId)
try c.encodeIfPresent(foodId, forKey: .foodId)
try c.encode(mealType, forKey: .mealType)
try c.encode(quantity, forKey: .quantity)
try c.encode(entryDate, forKey: .entryDate)
try c.encode(foodName, forKey: .foodName)
try c.encode(calories, forKey: .calories)
try c.encode(protein, forKey: .protein)
try c.encode(carbs, forKey: .carbs)
try c.encode(fat, forKey: .fat)
try c.encodeIfPresent(sugar, forKey: .sugar)
try c.encodeIfPresent(fiber, forKey: .fiber)
try c.encodeIfPresent(servingSize, forKey: .servingSize)
try c.encodeIfPresent(imageFilename, forKey: .imageFilename)
}
// Flexible Int decoding
private static func decodeIntFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Int {
if let v = try? c.decode(Int.self, forKey: key) { return v }
if let v = try? c.decode(Double.self, forKey: key) { return Int(v) }
if let v = try? c.decode(String.self, forKey: key), let i = Int(v) { return i }
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
}
// Flexible Double decoding
private static func decodeDoubleFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Double {
if let v = try? c.decode(Double.self, forKey: key) { return v }
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
if let v = try? c.decode(String.self, forKey: key), let d = Double(v) { return d }
throw DecodingError.typeMismatch(Double.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
}
}
// MARK: - Food Item
struct FoodItem: Identifiable, Codable {
let id: Int
let name: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
let servingSize: String?
let imageFilename: String?
enum CodingKeys: String, CodingKey {
case id, name, calories, protein, carbs, fat, sugar, fiber
case servingSize = "serving_size"
case imageFilename = "image_filename"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
else { id = 0 }
name = try c.decode(String.self, forKey: .name)
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 0)
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 0)
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 0)
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 0)
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? (try? c.decode(Int.self, forKey: .sugar)).map { Double($0) }
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? (try? c.decode(Int.self, forKey: .fiber)).map { Double($0) }
servingSize = try? c.decode(String.self, forKey: .servingSize)
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
}
}
// MARK: - Daily Goal
struct DailyGoal: Codable {
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double
let fiber: Double
enum CodingKeys: String, CodingKey {
case calories, protein, carbs, fat, sugar, fiber
}
init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
self.calories = calories
self.protein = protein
self.carbs = carbs
self.fat = fat
self.sugar = sugar
self.fiber = fiber
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 2000)
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 150)
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 250)
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 65)
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? Double((try? c.decode(Int.self, forKey: .sugar)) ?? 50)
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? Double((try? c.decode(Int.self, forKey: .fiber)) ?? 30)
}
}
// MARK: - Meal Template
struct MealTemplate: Identifiable, Codable {
let id: Int
let name: String
let mealType: MealType
let totalCalories: Double?
let totalProtein: Double?
let totalCarbs: Double?
let totalFat: Double?
let itemCount: Int?
enum CodingKeys: String, CodingKey {
case id, name
case mealType = "meal_type"
case totalCalories = "total_calories"
case totalProtein = "total_protein"
case totalCarbs = "total_carbs"
case totalFat = "total_fat"
case itemCount = "item_count"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
else { id = 0 }
name = try c.decode(String.self, forKey: .name)
mealType = try c.decode(MealType.self, forKey: .mealType)
totalCalories = (try? c.decode(Double.self, forKey: .totalCalories)) ?? (try? c.decode(Int.self, forKey: .totalCalories)).map { Double($0) }
totalProtein = (try? c.decode(Double.self, forKey: .totalProtein)) ?? (try? c.decode(Int.self, forKey: .totalProtein)).map { Double($0) }
totalCarbs = (try? c.decode(Double.self, forKey: .totalCarbs)) ?? (try? c.decode(Int.self, forKey: .totalCarbs)).map { Double($0) }
totalFat = (try? c.decode(Double.self, forKey: .totalFat)) ?? (try? c.decode(Int.self, forKey: .totalFat)).map { Double($0) }
itemCount = (try? c.decode(Int.self, forKey: .itemCount)) ?? (try? c.decode(Double.self, forKey: .itemCount)).map { Int($0) }
}
}
// MARK: - Requests
struct CreateEntryRequest: Encodable {
let foodId: Int?
let foodName: String
let mealType: String
let quantity: Double
let entryDate: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
enum CodingKeys: String, CodingKey {
case foodId = "food_id"
case foodName = "food_name"
case mealType = "meal_type"
case quantity
case entryDate = "entry_date"
case calories, protein, carbs, fat, sugar, fiber
}
}
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) ?? ""
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
@Observable
final class FitnessRepository {
static let shared = FitnessRepository()
var entries: [FoodEntry] = []
var goals: DailyGoal = DailyGoal()
var isLoading = false
var error: String?
private let api = FitnessAPI()
func loadDay(date: String) async {
isLoading = true
error = nil
do {
async let e = api.getEntries(date: date)
async let g = api.getGoals(date: date)
entries = try await e
goals = try await g
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func deleteEntry(id: Int) async {
do {
try await api.deleteEntry(id: id)
entries.removeAll { $0.id == id }
} catch {
self.error = error.localizedDescription
}
}
func addEntry(_ req: CreateEntryRequest) async {
do {
let entry = try await api.createEntry(req)
entries.append(entry)
} catch {
self.error = error.localizedDescription
}
}
func updateEntry(id: Int, quantity: Double) async -> FoodEntry? {
do {
let updated = try await api.updateEntry(id: id, quantity: quantity)
if let idx = entries.firstIndex(where: { $0.id == id }) {
entries[idx] = updated
}
return updated
} catch {
self.error = error.localizedDescription
return nil
}
}
// Computed helpers
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 }
}
}

View File

@@ -0,0 +1,51 @@
import Foundation
@Observable
final class FoodSearchViewModel {
var query = ""
var results: [FoodItem] = []
var recentFoods: [FoodItem] = []
var allFoods: [FoodItem] = []
var isSearching = false
var isLoadingInitial = false
private let api = FitnessAPI()
private var searchTask: Task<Void, Never>?
func loadInitial() async {
isLoadingInitial = true
do {
async let r = api.getRecentFoods()
async let a = api.getFoods()
recentFoods = try await r
allFoods = try await a
} catch {
// Silently fail
}
isLoadingInitial = false
}
func search() {
searchTask?.cancel()
let q = query.trimmingCharacters(in: .whitespaces)
guard q.count >= 2 else {
results = []
isSearching = false
return
}
isSearching = true
searchTask = Task {
do {
let items = try await api.searchFoods(query: q)
if !Task.isCancelled {
results = items
isSearching = false
}
} catch {
if !Task.isCancelled {
isSearching = false
}
}
}
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
@Observable
final class GoalsViewModel {
var calories: String = ""
var protein: String = ""
var carbs: String = ""
var fat: String = ""
var sugar: String = ""
var fiber: String = ""
var isLoading = false
var isSaving = false
var error: String?
var saved = false
private let api = FitnessAPI()
func load(date: String) async {
isLoading = true
do {
let goals = try await api.getGoals(date: date)
calories = String(Int(goals.calories))
protein = String(Int(goals.protein))
carbs = String(Int(goals.carbs))
fat = String(Int(goals.fat))
sugar = String(Int(goals.sugar))
fiber = String(Int(goals.fiber))
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func save() async {
isSaving = true
error = nil
saved = false
let req = UpdateGoalsRequest(
calories: Double(calories) ?? 2000,
protein: Double(protein) ?? 150,
carbs: Double(carbs) ?? 250,
fat: Double(fat) ?? 65,
sugar: Double(sugar) ?? 50,
fiber: Double(fiber) ?? 30
)
do {
_ = try await api.updateGoals(req)
saved = true
} catch {
self.error = error.localizedDescription
}
isSaving = false
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
@Observable
final class TemplatesViewModel {
var templates: [MealTemplate] = []
var isLoading = false
var error: String?
var logSuccess: String?
private let api = FitnessAPI()
func load() async {
isLoading = true
error = nil
do {
templates = try await api.getTemplates()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func logTemplate(_ template: MealTemplate, date: String) async {
do {
try await api.logTemplate(id: template.id, date: date)
logSuccess = "Logged \(template.name)"
// Refresh the repository
await FitnessRepository.shared.loadDay(date: date)
} catch {
self.error = error.localizedDescription
}
}
var groupedByMeal: [MealType: [MealTemplate]] {
Dictionary(grouping: templates, by: { $0.mealType })
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
@Observable
final class TodayViewModel {
var selectedDate: Date = Date()
let repository = FitnessRepository.shared
var dateString: String {
selectedDate.apiDateString
}
var displayDate: String {
if selectedDate.isToday {
return "Today"
}
return selectedDate.displayString
}
func load() async {
await repository.loadDay(date: dateString)
}
func previousDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
Task { await load() }
}
func nextDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
Task { await load() }
}
func deleteEntry(_ entry: FoodEntry) async {
await repository.deleteEntry(id: entry.id)
}
}

View File

@@ -0,0 +1,171 @@
import SwiftUI
struct AddFoodSheet: View {
let food: FoodItem
let mealType: MealType
let dateString: String
let onAdded: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var quantity: Double = 1.0
@State private var selectedMeal: MealType
@State private var isAdding = false
init(food: FoodItem, mealType: MealType, dateString: String, onAdded: @escaping () -> Void) {
self.food = food
self.mealType = mealType
self.dateString = dateString
self.onAdded = onAdded
_selectedMeal = State(initialValue: mealType)
}
private var scaledCalories: Double { food.calories * quantity }
private var scaledProtein: Double { food.protein * quantity }
private var scaledCarbs: Double { food.carbs * quantity }
private var scaledFat: Double { food.fat * quantity }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Food header
VStack(spacing: 8) {
if let img = food.imageFilename {
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.surfaceSecondary
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text(food.name)
.font(.title3.bold())
.foregroundStyle(Color.textPrimary)
if let serving = food.servingSize {
Text(serving)
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
}
// Quantity
VStack(spacing: 8) {
Text("Quantity")
.font(.subheadline.bold())
.foregroundStyle(Color.textSecondary)
HStack(spacing: 16) {
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
Text(String(format: "%.1f", quantity))
.font(.title2.bold())
.frame(minWidth: 60)
Button { quantity += 0.5 } label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
}
}
// Meal picker
VStack(spacing: 8) {
Text("Meal")
.font(.subheadline.bold())
.foregroundStyle(Color.textSecondary)
HStack(spacing: 8) {
ForEach(MealType.allCases) { meal in
Button {
selectedMeal = meal
} label: {
Text(meal.displayName)
.font(.caption.bold())
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(selectedMeal == meal ? meal.color.opacity(0.2) : Color.surfaceSecondary)
.foregroundStyle(selectedMeal == meal ? meal.color : Color.textSecondary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// Nutrition preview
VStack(spacing: 8) {
nutritionRow("Calories", "\(Int(scaledCalories))")
nutritionRow("Protein", "\(Int(scaledProtein))g")
nutritionRow("Carbs", "\(Int(scaledCarbs))g")
nutritionRow("Fat", "\(Int(scaledFat))g")
}
.padding(16)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Add button
Button {
isAdding = true
Task {
let req = CreateEntryRequest(
foodId: food.id,
foodName: food.name,
mealType: selectedMeal.rawValue,
quantity: quantity,
entryDate: dateString,
calories: food.calories,
protein: food.protein,
carbs: food.carbs,
fat: food.fat,
sugar: food.sugar,
fiber: food.fiber
)
await FitnessRepository.shared.addEntry(req)
isAdding = false
dismiss()
onAdded()
}
} label: {
Group {
if isAdding {
ProgressView().tint(.white)
} else {
Text("Add")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(20)
}
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
private func nutritionRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
Spacer()
Text(value)
.font(.subheadline.bold())
.foregroundStyle(Color.textPrimary)
}
}
}

View File

@@ -0,0 +1,160 @@
import SwiftUI
struct EntryDetailView: View {
let entry: FoodEntry
let dateString: String
@Environment(\.dismiss) private var dismiss
@State private var quantity: Double
@State private var isDeleting = false
@State private var isSaving = false
init(entry: FoodEntry, dateString: String) {
self.entry = entry
self.dateString = dateString
_quantity = State(initialValue: entry.quantity)
}
private var scaledCalories: Double { entry.calories * quantity }
private var scaledProtein: Double { entry.protein * quantity }
private var scaledCarbs: Double { entry.carbs * quantity }
private var scaledFat: Double { entry.fat * quantity }
private var scaledSugar: Double? { entry.sugar.map { $0 * quantity } }
private var scaledFiber: Double? { entry.fiber.map { $0 * quantity } }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Food name
Text(entry.foodName)
.font(.title2.bold())
.foregroundStyle(Color.textPrimary)
// Meal badge
HStack {
Image(systemName: entry.mealType.icon)
Text(entry.mealType.displayName)
}
.font(.caption.bold())
.foregroundStyle(entry.mealType.color)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(entry.mealType.color.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
// Quantity editor
VStack(spacing: 8) {
Text("Quantity")
.font(.subheadline.bold())
.foregroundStyle(Color.textSecondary)
HStack(spacing: 16) {
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
Text(String(format: "%.1f", quantity))
.font(.title2.bold())
.frame(minWidth: 60)
Button { quantity += 0.5 } label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
}
}
if quantity != entry.quantity {
Button {
isSaving = true
Task {
_ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity)
isSaving = false
dismiss()
}
} label: {
Group {
if isSaving {
ProgressView().tint(.white)
} else {
Text("Update Quantity")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// Nutrition grid
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
nutritionCell("Calories", "\(Int(scaledCalories))", "kcal")
nutritionCell("Protein", "\(Int(scaledProtein))", "g")
nutritionCell("Carbs", "\(Int(scaledCarbs))", "g")
nutritionCell("Fat", "\(Int(scaledFat))", "g")
if let sugar = scaledSugar {
nutritionCell("Sugar", "\(Int(sugar))", "g")
}
if let fiber = scaledFiber {
nutritionCell("Fiber", "\(Int(fiber))", "g")
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
// Delete
Button(role: .destructive) {
isDeleting = true
Task {
await FitnessRepository.shared.deleteEntry(id: entry.id)
isDeleting = false
dismiss()
}
} label: {
HStack {
if isDeleting {
ProgressView().tint(.red)
} else {
Image(systemName: "trash")
Text("Delete Entry")
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.bordered)
.tint(.red)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(20)
}
.background(Color.canvas)
.navigationTitle("Entry Detail")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func nutritionCell(_ label: String, _ value: String, _ unit: String) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.title3.bold())
.foregroundStyle(Color.textPrimary)
Text("\(label) (\(unit))")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}

View File

@@ -0,0 +1,83 @@
import SwiftUI
enum FitnessTab: String, CaseIterable {
case today = "Today"
case templates = "Templates"
case goals = "Goals"
case foods = "Foods"
}
struct FitnessTabView: View {
@State private var selectedTab: FitnessTab = .today
@State private var showAssistant = false
@State private var todayVM = TodayViewModel()
var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
VStack(spacing: 0) {
// Tab bar
HStack(spacing: 24) {
ForEach(FitnessTab.allCases, id: \.self) { tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
} label: {
Text(tab.rawValue)
.font(.subheadline)
.fontWeight(selectedTab == tab ? .bold : .medium)
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
.padding(.bottom, 8)
.overlay(alignment: .bottom) {
if selectedTab == tab {
Rectangle()
.fill(Color.accent)
.frame(height: 2)
}
}
}
}
}
.padding(.top, 60)
.padding(.horizontal, 24)
// Content
TabView(selection: $selectedTab) {
TodayView(viewModel: todayVM)
.tag(FitnessTab.today)
TemplatesView(dateString: todayVM.dateString)
.tag(FitnessTab.templates)
GoalsView(dateString: todayVM.dateString)
.tag(FitnessTab.goals)
FoodLibraryView(dateString: todayVM.dateString)
.tag(FitnessTab.foods)
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
// Floating + button
Button {
showAssistant = true
} label: {
Image(systemName: "plus")
.font(.title2.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accent)
.clipShape(Circle())
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
.background(Color.canvas.ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showAssistant) {
AssistantChatView(entryDate: todayVM.dateString) {
Task { await todayVM.load() }
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct FoodLibraryView: View {
let dateString: String
@State private var vm = FoodSearchViewModel()
@State private var selectedFood: FoodItem?
var body: some View {
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.textTertiary)
TextField("Search foods...", text: $vm.query)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onChange(of: vm.query) { _, _ in
vm.search()
}
if !vm.query.isEmpty {
Button { vm.query = ""; vm.results = [] } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textTertiary)
}
}
}
.padding(12)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 8)
if vm.isLoadingInitial {
LoadingView()
} else {
let foods = vm.query.count >= 2 ? vm.results : vm.allFoods
if foods.isEmpty {
EmptyStateView(icon: "fork.knife", title: "No foods", subtitle: "Your food library is empty")
} else {
List(foods) { food in
Button {
selectedFood = food
} label: {
HStack {
if let img = food.imageFilename {
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.surfaceSecondary
}
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
HStack(spacing: 8) {
Text("\(Int(food.calories)) cal")
Text("P:\(Int(food.protein))g")
Text("C:\(Int(food.carbs))g")
Text("F:\(Int(food.fat))g")
}
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
Spacer()
}
}
}
.listStyle(.plain)
}
}
}
.task {
await vm.loadInitial()
}
.sheet(item: $selectedFood) { food in
AddFoodSheet(food: food, mealType: .snack, dateString: dateString) {}
}
}
}

View File

@@ -0,0 +1,119 @@
import SwiftUI
struct FoodSearchView: View {
let mealType: MealType
let dateString: String
@Environment(\.dismiss) private var dismiss
@State private var vm = FoodSearchViewModel()
@State private var selectedFood: FoodItem?
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.textTertiary)
TextField("Search foods...", text: $vm.query)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onChange(of: vm.query) { _, _ in
vm.search()
}
if !vm.query.isEmpty {
Button { vm.query = ""; vm.results = [] } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textTertiary)
}
}
}
.padding(12)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 8)
if vm.isSearching || vm.isLoadingInitial {
LoadingView()
} else if !vm.query.isEmpty && vm.query.count >= 2 {
// Search results
List {
Section {
ForEach(vm.results) { food in
foodRow(food)
}
} header: {
Text("\(vm.results.count) results")
}
}
.listStyle(.plain)
} else {
// Default: recent + all
List {
if !vm.recentFoods.isEmpty {
Section("Recent") {
ForEach(vm.recentFoods) { food in
foodRow(food)
}
}
}
if !vm.allFoods.isEmpty {
Section("All Foods") {
ForEach(vm.allFoods) { food in
foodRow(food)
}
}
}
}
.listStyle(.plain)
}
}
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.task {
await vm.loadInitial()
}
.sheet(item: $selectedFood) { food in
AddFoodSheet(food: food, mealType: mealType, dateString: dateString) {
dismiss()
}
}
}
private func foodRow(_ food: FoodItem) -> some View {
Button {
selectedFood = food
} label: {
HStack {
if let img = food.imageFilename {
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.surfaceSecondary
}
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
Text("\(Int(food.calories)) cal")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
Spacer()
Image(systemName: "plus.circle")
.foregroundStyle(Color.accent)
}
}
}
}

View File

@@ -0,0 +1,85 @@
import SwiftUI
struct GoalsView: View {
let dateString: String
@State private var vm = GoalsViewModel()
var body: some View {
ScrollView {
VStack(spacing: 16) {
if vm.isLoading {
LoadingView()
} else {
VStack(spacing: 12) {
goalField("Calories", value: $vm.calories, unit: "kcal")
goalField("Protein", value: $vm.protein, unit: "g")
goalField("Carbs", value: $vm.carbs, unit: "g")
goalField("Fat", value: $vm.fat, unit: "g")
goalField("Sugar", value: $vm.sugar, unit: "g")
goalField("Fiber", value: $vm.fiber, unit: "g")
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal, 16)
Button {
Task { await vm.save() }
} label: {
Group {
if vm.isSaving {
ProgressView().tint(.white)
} else {
Text("Save Goals")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.disabled(vm.isSaving)
if vm.saved {
Text("Goals saved!")
.font(.caption)
.foregroundStyle(Color.emerald)
}
if let err = vm.error {
ErrorBanner(message: err)
}
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task {
await vm.load(date: dateString)
}
}
private func goalField(_ label: String, value: Binding<String>, unit: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.frame(width: 80, alignment: .leading)
TextField("0", text: value)
.keyboardType(.numberPad)
.textFieldStyle(.plain)
.font(.subheadline.bold())
.padding(10)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(unit)
.font(.caption)
.foregroundStyle(Color.textTertiary)
.frame(width: 32)
}
}
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
struct MealSectionView: View {
let meal: MealType
let entries: [FoodEntry]
let mealCalories: Double
let onDelete: (FoodEntry) -> Void
let dateString: String
@State private var isExpanded = true
@State private var showFoodSearch = false
@State private var selectedEntry: FoodEntry?
var body: some View {
VStack(spacing: 0) {
// Header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 0) {
// Colored accent bar
RoundedRectangle(cornerRadius: 2)
.fill(meal.color)
.frame(width: 4, height: 44)
.padding(.trailing, 12)
Image(systemName: meal.icon)
.font(.headline)
.foregroundStyle(meal.color)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(meal.displayName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Text("\(entries.count) item\(entries.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
.padding(.leading, 8)
Spacer()
Text("\(Int(mealCalories)) cal")
.font(.subheadline.bold())
.foregroundStyle(meal.color)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.padding(.leading, 8)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
// Entries
if isExpanded && !entries.isEmpty {
VStack(spacing: 0) {
ForEach(entries) { entry in
Button {
selectedEntry = entry
} label: {
entryRow(entry)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
onDelete(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(.leading, 40)
}
// Add button
if isExpanded {
Button {
showFoodSearch = true
} label: {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundStyle(meal.color.opacity(0.6))
Text("Add food")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
.padding(.vertical, 8)
.padding(.leading, 44)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.background(meal.color.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.sheet(isPresented: $showFoodSearch) {
FoodSearchView(mealType: meal, dateString: dateString)
}
.sheet(item: $selectedEntry) { entry in
EntryDetailView(entry: entry, dateString: dateString)
}
}
private func entryRow(_ entry: FoodEntry) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(entry.foodName)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
.lineLimit(1)
if entry.quantity != 1 {
Text("x\(String(format: "%.1f", entry.quantity))")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
}
Spacer()
Text("\(Int(entry.calories * entry.quantity))")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
.padding(.vertical, 6)
.padding(.horizontal, 16)
}
}

View File

@@ -0,0 +1,103 @@
import SwiftUI
struct TemplatesView: View {
let dateString: String
@State private var vm = TemplatesViewModel()
@State private var templateToLog: MealTemplate?
@State private var showConfirm = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
if vm.isLoading {
LoadingView()
} else if vm.templates.isEmpty {
EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app")
} else {
ForEach(MealType.allCases) { meal in
let templates = vm.groupedByMeal[meal] ?? []
if !templates.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: meal.icon)
.foregroundStyle(meal.color)
Text(meal.displayName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
}
.padding(.horizontal, 16)
ForEach(templates) { template in
templateCard(template)
}
}
}
}
}
if let msg = vm.logSuccess {
Text(msg)
.font(.caption)
.foregroundStyle(Color.emerald)
.padding(.top, 4)
}
if let err = vm.error {
ErrorBanner(message: err)
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task {
await vm.load()
}
.alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in
Button("Log") {
Task { await vm.logTemplate(template, date: dateString) }
}
Button("Cancel", role: .cancel) {}
} message: { template in
Text("Add \(template.name) to today's log?")
}
}
private func templateCard(_ template: MealTemplate) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(template.name)
.font(.subheadline.bold())
.foregroundStyle(Color.textPrimary)
HStack(spacing: 8) {
if let cal = template.totalCalories {
Text("\(Int(cal)) cal")
}
if let items = template.itemCount {
Text("\(items) items")
}
}
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
Spacer()
Button {
templateToLog = template
showConfirm = true
} label: {
Text("Log meal")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(14)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.03), radius: 4, y: 1)
.padding(.horizontal, 16)
}
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
struct TodayView: View {
@Bindable var viewModel: TodayViewModel
@State private var showFoodSearch = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
// Date selector
HStack {
Button { viewModel.previousDay() } label: {
Image(systemName: "chevron.left")
.font(.headline)
.foregroundStyle(Color.accent)
}
Spacer()
Text(viewModel.displayDate)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Spacer()
Button { viewModel.nextDay() } label: {
Image(systemName: "chevron.right")
.font(.headline)
.foregroundStyle(Color.accent)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.gesture(
DragGesture(minimumDistance: 50)
.onEnded { value in
if value.translation.width > 0 {
viewModel.previousDay()
} else {
viewModel.nextDay()
}
}
)
// Macro summary card
if !viewModel.repository.isLoading {
macroSummaryCard
}
// Error
if let error = viewModel.repository.error {
ErrorBanner(message: error) { await viewModel.load() }
}
// Meal sections
ForEach(MealType.allCases) { meal in
MealSectionView(
meal: meal,
entries: viewModel.repository.entriesForMeal(meal),
mealCalories: viewModel.repository.mealCalories(meal),
onDelete: { entry in
Task { await viewModel.deleteEntry(entry) }
},
dateString: viewModel.dateString
)
}
Spacer(minLength: 80)
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
private var macroSummaryCard: some View {
let repo = viewModel.repository
let goals = repo.goals
return VStack(spacing: 12) {
HStack(spacing: 20) {
LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories)
VStack(spacing: 8) {
MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue)
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange)
MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple)
}
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
.padding(.horizontal, 16)
}
}

View File

@@ -0,0 +1,161 @@
import SwiftUI
import PhotosUI
struct HomeView: View {
@Environment(AuthManager.self) private var authManager
@State private var vm = HomeViewModel()
@State private var showAssistant = false
@State private var showProfileMenu = false
@State private var selectedPhoto: PhotosPickerItem?
var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
// Background
if let bg = vm.backgroundImage {
Image(uiImage: bg)
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea()
.overlay(Color.black.opacity(0.2).ignoresSafeArea())
}
else {
Color.canvas.ignoresSafeArea()
}
ScrollView {
VStack(spacing: 16) {
// Top bar
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Dashboard")
.font(.title2.bold())
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary)
if let name = authManager.user?.displayName ?? authManager.user?.username {
Text("Welcome, \(name)")
.font(.subheadline)
.foregroundStyle(vm.backgroundImage != nil ? .white.opacity(0.8) : Color.textSecondary)
}
}
Spacer()
Menu {
PhotosPicker(selection: $selectedPhoto, matching: .images) {
Label("Change Background", systemImage: "photo")
}
if vm.backgroundImage != nil {
Button(role: .destructive) {
vm.removeBackground()
} label: {
Label("Remove Background", systemImage: "trash")
}
}
Divider()
Button(role: .destructive) {
authManager.logout()
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Image(systemName: "person.circle.fill")
.font(.title2)
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accent)
}
}
.padding(.horizontal, 20)
.padding(.top, 60)
// Widget grid
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
calorieWidget
quickStatsWidget
}
.padding(.horizontal, 16)
Spacer(minLength: 100)
}
}
// Floating + button
Button {
showAssistant = true
} label: {
Image(systemName: "plus")
.font(.title2.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accent)
.clipShape(Circle())
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showAssistant) {
AssistantChatView(entryDate: Date().apiDateString) {
Task { await vm.loadData() }
}
}
.onChange(of: selectedPhoto) { _, newValue in
guard let item = newValue else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
vm.setBackground(image)
}
selectedPhoto = nil
}
}
.task {
await vm.loadData()
}
}
}
private var calorieWidget: some View {
let hasBg = vm.backgroundImage != nil
return VStack(spacing: 8) {
LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal)
Text("Calories")
.font(.caption.bold())
.foregroundStyle(hasBg ? .white : Color.textSecondary)
}
.padding(16)
.frame(maxWidth: .infinity)
.background {
if hasBg {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(Color.surface)
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
}
}
}
private var quickStatsWidget: some View {
let hasBg = vm.backgroundImage != nil
let repo = FitnessRepository.shared
return VStack(alignment: .leading, spacing: 10) {
Text("Macros")
.font(.caption.bold())
.foregroundStyle(hasBg ? .white : Color.textSecondary)
MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true)
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: repo.goals.carbs, color: .orange, compact: true)
MacroBar(label: "Fat", value: repo.totalFat, goal: repo.goals.fat, color: .purple, compact: true)
}
.padding(16)
.frame(maxWidth: .infinity)
.background {
if hasBg {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(Color.surface)
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
}
}
}
}

View File

@@ -0,0 +1,54 @@
import SwiftUI
import PhotosUI
@Observable
final class HomeViewModel {
var backgroundImage: UIImage?
var caloriesConsumed: Double = 0
var caloriesGoal: Double = 2000
var isLoading = false
private let bgKey = "homeBackgroundImage"
private let repo = FitnessRepository.shared
init() {
loadBackgroundFromDefaults()
}
func loadData() async {
isLoading = true
let today = Date().apiDateString
await repo.loadDay(date: today)
caloriesConsumed = repo.totalCalories
caloriesGoal = repo.goals.calories
isLoading = false
}
func setBackground(_ image: UIImage) {
// Resize to max 1200px
let maxDim: CGFloat = 1200
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
image.draw(in: CGRect(origin: .zero, size: newSize))
let resized = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
if let resized, let data = resized.jpegData(compressionQuality: 0.8) {
UserDefaults.standard.set(data, forKey: bgKey)
backgroundImage = resized
}
}
func removeBackground() {
UserDefaults.standard.removeObject(forKey: bgKey)
backgroundImage = nil
}
private func loadBackgroundFromDefaults() {
if let data = UserDefaults.standard.data(forKey: bgKey),
let img = UIImage(data: data) {
backgroundImage = img
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct PlatformApp: App {
@State private var authManager = AuthManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(authManager)
}
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct LoadingView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 12) {
ProgressView()
.tint(.accent)
Text(message)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ErrorBanner: View {
let message: String
var retry: (() async -> Void)?
var body: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(message)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
Spacer()
if let retry = retry {
Button("Retry") {
Task { await retry() }
}
.font(.subheadline.bold())
.foregroundStyle(Color.accent)
}
}
.padding(12)
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
}
}
struct EmptyStateView: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundStyle(Color.textTertiary)
Text(title)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
}
.padding(40)
.frame(maxWidth: .infinity)
}
}

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct MacroBar: View {
let label: String
let value: Double
let goal: Double
let color: Color
var compact: Bool = false
private var progress: Double {
guard goal > 0 else { return 0 }
return min(max(value / goal, 0), 1)
}
var body: some View {
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
HStack {
Text(label)
.font(compact ? .caption2 : .caption)
.fontWeight(.medium)
.foregroundStyle(Color.textSecondary)
Spacer()
Text("\(Int(value))/\(Int(goal))g")
.font(compact ? .caption2 : .caption)
.fontWeight(.semibold)
.foregroundStyle(Color.textPrimary)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: compact ? 2 : 3)
.fill(color.opacity(0.15))
.frame(height: compact ? 4 : 6)
RoundedRectangle(cornerRadius: compact ? 2 : 3)
.fill(color)
.frame(width: max(0, geo.size.width * progress), height: compact ? 4 : 6)
.animation(.easeInOut(duration: 0.5), value: progress)
}
}
.frame(height: compact ? 4 : 6)
}
}
}

View File

@@ -0,0 +1,83 @@
import SwiftUI
struct MacroRing: View {
let consumed: Double
let goal: Double
let color: Color
var size: CGFloat = 80
var lineWidth: CGFloat = 8
var showLabel: Bool = true
var labelFontSize: CGFloat = 14
private var progress: Double {
guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1)
}
var body: some View {
ZStack {
Circle()
.stroke(color.opacity(0.15), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: progress)
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.6), value: progress)
if showLabel {
VStack(spacing: 0) {
Text("\(Int(consumed))")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
if goal > 0 {
Text("/ \(Int(goal))")
.font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded))
.foregroundStyle(Color.textSecondary)
}
}
}
}
.frame(width: size, height: size)
}
}
struct LargeCalorieRing: View {
let consumed: Double
let goal: Double
private var remaining: Int {
max(0, Int(goal) - Int(consumed))
}
private var progress: Double {
guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1)
}
var body: some View {
ZStack {
Circle()
.stroke(Color.emerald.opacity(0.15), lineWidth: 14)
Circle()
.trim(from: 0, to: progress)
.stroke(
Color.emerald,
style: StrokeStyle(lineWidth: 14, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.8), value: progress)
VStack(spacing: 2) {
Text("\(Int(consumed))")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
Text("\(remaining) left")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(Color.textSecondary)
}
}
.frame(width: 120, height: 120)
}
}

View File

@@ -0,0 +1,47 @@
import SwiftUI
extension Color {
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 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8:
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
// Core palette
static let canvas = Color(hex: "F5EFE6")
static let surface = Color(hex: "FFFFFF")
static let surfaceSecondary = Color(hex: "FAF7F2")
static let accent = Color(hex: "8B6914")
static let accentLight = Color(hex: "D4A843")
static let emerald = Color(hex: "059669")
static let textPrimary = Color(hex: "1C1917")
static let textSecondary = Color(hex: "78716C")
static let textTertiary = Color(hex: "A8A29E")
static let border = Color(hex: "E7E5E4")
// Meal colors
static let breakfastColor = Color(hex: "F59E0B")
static let lunchColor = Color(hex: "059669")
static let dinnerColor = Color(hex: "8B5CF6")
static let snackColor = Color(hex: "EC4899")
// Chat
static let userBubble = Color(hex: "8B6914").opacity(0.15)
static let assistantBubble = Color(hex: "F5F5F4")
}

View File

@@ -0,0 +1,33 @@
import Foundation
extension Date {
var apiDateString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.string(from: self)
}
var displayString: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: self)
}
var shortDisplayString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return formatter.string(from: self)
}
var isToday: Bool {
Calendar.current.isDateInToday(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)
}
}