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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,575 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A10001 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10001 /* PlatformApp.swift */; };
A10002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10002 /* ContentView.swift */; };
A10003 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10003 /* Config.swift */; };
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004 /* APIClient.swift */; };
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; };
A10009 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10009 /* FitnessModels.swift */; };
A10010 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10010 /* FitnessAPI.swift */; };
A10011 /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10011 /* FitnessRepository.swift */; };
A10012 /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10012 /* FitnessTabView.swift */; };
A10013 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10013 /* TodayView.swift */; };
A10014 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10014 /* MealSectionView.swift */; };
A10015 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10015 /* FoodSearchView.swift */; };
A10016 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10016 /* AddFoodSheet.swift */; };
A10017 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10017 /* HistoryView.swift */; };
A10018 /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10018 /* TemplatesView.swift */; };
A10019 /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10019 /* GoalsView.swift */; };
A10020 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10020 /* EntryDetailView.swift */; };
A10021 /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10021 /* TodayViewModel.swift */; };
A10022 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10022 /* FoodSearchViewModel.swift */; };
A10023 /* HistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10023 /* HistoryViewModel.swift */; };
A10024 /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10024 /* TemplatesViewModel.swift */; };
A10025 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10025 /* GoalsViewModel.swift */; };
A10026 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10026 /* MacroRing.swift */; };
A10027 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10027 /* MacroBar.swift */; };
A10028 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10028 /* LoadingView.swift */; };
A10029 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10029 /* Date+Extensions.swift */; };
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030 /* Color+Extensions.swift */; };
A10031 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10031 /* Assets.xcassets */; };
A10032 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10032 /* FoodLibraryView.swift */; };
F2B322572F7F89B600368ED5 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B322562F7F89B600368ED5 /* AssistantChatView.swift */; };
F2B322592F7F89CC00368ED5 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B322582F7F89CC00368ED5 /* AssistantViewModel.swift */; };
F2B3225B2F7F89DC00368ED5 /* AssistantModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B3225A2F7F89DC00368ED5 /* AssistantModels.swift */; };
F2B3225D2F7F89EF00368ED5 /* ImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B3225C2F7F89EF00368ED5 /* ImageCropView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
B10000 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
B10001 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformApp.swift; sourceTree = "<group>"; };
B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B10003 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
B10004 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
B10009 /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessModels.swift; sourceTree = "<group>"; };
B10010 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessAPI.swift; sourceTree = "<group>"; };
B10011 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessRepository.swift; sourceTree = "<group>"; };
B10012 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessTabView.swift; sourceTree = "<group>"; };
B10013 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
B10014 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSectionView.swift; sourceTree = "<group>"; };
B10015 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchView.swift; sourceTree = "<group>"; };
B10016 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFoodSheet.swift; sourceTree = "<group>"; };
B10017 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
B10018 /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesView.swift; sourceTree = "<group>"; };
B10019 /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsView.swift; sourceTree = "<group>"; };
B10020 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDetailView.swift; sourceTree = "<group>"; };
B10021 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = "<group>"; };
B10022 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchViewModel.swift; sourceTree = "<group>"; };
B10023 /* HistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewModel.swift; sourceTree = "<group>"; };
B10024 /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesViewModel.swift; sourceTree = "<group>"; };
B10025 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsViewModel.swift; sourceTree = "<group>"; };
B10026 /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroRing.swift; sourceTree = "<group>"; };
B10027 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroBar.swift; sourceTree = "<group>"; };
B10028 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
B10029 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
B10031 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B10032 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodLibraryView.swift; sourceTree = "<group>"; };
F2B322562F7F89B600368ED5 /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AssistantChatView.swift; path = Platform/Features/Assistant/AssistantChatView.swift; sourceTree = "<group>"; };
F2B322582F7F89CC00368ED5 /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AssistantViewModel.swift; path = Platform/Features/Assistant/AssistantViewModel.swift; sourceTree = "<group>"; };
F2B3225A2F7F89DC00368ED5 /* AssistantModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AssistantModels.swift; path = Platform/Features/Assistant/Models/AssistantModels.swift; sourceTree = "<group>"; };
F2B3225C2F7F89EF00368ED5 /* ImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ImageCropView.swift; path = Platform/Features/Home/ImageCropView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
C10001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
G10000 = {
isa = PBXGroup;
children = (
F2B3225C2F7F89EF00368ED5 /* ImageCropView.swift */,
F2B3225A2F7F89DC00368ED5 /* AssistantModels.swift */,
F2B322582F7F89CC00368ED5 /* AssistantViewModel.swift */,
F2B322562F7F89B600368ED5 /* AssistantChatView.swift */,
G10001 /* Platform */,
G10099 /* Products */,
);
sourceTree = "<group>";
};
G10001 /* Platform */ = {
isa = PBXGroup;
children = (
B10001 /* PlatformApp.swift */,
B10002 /* ContentView.swift */,
B10003 /* Config.swift */,
B10031 /* Assets.xcassets */,
G10002 /* Core */,
G10003 /* Features */,
G10004 /* Shared */,
);
path = Platform;
sourceTree = "<group>";
};
G10002 /* Core */ = {
isa = PBXGroup;
children = (
B10004 /* APIClient.swift */,
B10005 /* AuthManager.swift */,
);
path = Core;
sourceTree = "<group>";
};
G10003 /* Features */ = {
isa = PBXGroup;
children = (
G10010 /* Auth */,
G10011 /* Home */,
G10012 /* Fitness */,
);
path = Features;
sourceTree = "<group>";
};
G10004 /* Shared */ = {
isa = PBXGroup;
children = (
G10005 /* Components */,
G10006 /* Extensions */,
);
path = Shared;
sourceTree = "<group>";
};
G10005 /* Components */ = {
isa = PBXGroup;
children = (
B10026 /* MacroRing.swift */,
B10027 /* MacroBar.swift */,
B10028 /* LoadingView.swift */,
);
path = Components;
sourceTree = "<group>";
};
G10006 /* Extensions */ = {
isa = PBXGroup;
children = (
B10029 /* Date+Extensions.swift */,
B10030 /* Color+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
G10010 /* Auth */ = {
isa = PBXGroup;
children = (
B10006 /* LoginView.swift */,
);
path = Auth;
sourceTree = "<group>";
};
G10011 /* Home */ = {
isa = PBXGroup;
children = (
B10007 /* HomeView.swift */,
B10008 /* HomeViewModel.swift */,
);
path = Home;
sourceTree = "<group>";
};
G10012 /* Fitness */ = {
isa = PBXGroup;
children = (
G10013 /* Models */,
G10014 /* API */,
G10015 /* Repository */,
G10016 /* Views */,
G10017 /* ViewModels */,
);
path = Fitness;
sourceTree = "<group>";
};
G10013 /* Models */ = {
isa = PBXGroup;
children = (
B10009 /* FitnessModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
G10014 /* API */ = {
isa = PBXGroup;
children = (
B10010 /* FitnessAPI.swift */,
);
path = API;
sourceTree = "<group>";
};
G10015 /* Repository */ = {
isa = PBXGroup;
children = (
B10011 /* FitnessRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
G10016 /* Views */ = {
isa = PBXGroup;
children = (
B10012 /* FitnessTabView.swift */,
B10013 /* TodayView.swift */,
B10014 /* MealSectionView.swift */,
B10015 /* FoodSearchView.swift */,
B10016 /* AddFoodSheet.swift */,
B10017 /* HistoryView.swift */,
B10018 /* TemplatesView.swift */,
B10019 /* GoalsView.swift */,
B10020 /* EntryDetailView.swift */,
B10032 /* FoodLibraryView.swift */,
);
path = Views;
sourceTree = "<group>";
};
G10017 /* ViewModels */ = {
isa = PBXGroup;
children = (
B10021 /* TodayViewModel.swift */,
B10022 /* FoodSearchViewModel.swift */,
B10023 /* HistoryViewModel.swift */,
B10024 /* TemplatesViewModel.swift */,
B10025 /* GoalsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
G10099 /* Products */ = {
isa = PBXGroup;
children = (
B10000 /* Platform.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
T10001 /* Platform */ = {
isa = PBXNativeTarget;
buildConfigurationList = CL10002 /* Build configuration list for PBXNativeTarget "Platform" */;
buildPhases = (
S10001 /* Sources */,
C10001 /* Frameworks */,
R10001 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Platform;
productName = Platform;
productReference = B10000 /* Platform.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
P10001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
TargetAttributes = {
T10001 = {
CreatedOnToolsVersion = 15.4;
};
};
};
buildConfigurationList = CL10001 /* Build configuration list for PBXProject "Platform" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = G10000;
productRefGroup = G10099 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
T10001 /* Platform */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
R10001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10031 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
S10001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10001 /* PlatformApp.swift in Sources */,
F2B322592F7F89CC00368ED5 /* AssistantViewModel.swift in Sources */,
A10002 /* ContentView.swift in Sources */,
A10003 /* Config.swift in Sources */,
A10004 /* APIClient.swift in Sources */,
A10005 /* AuthManager.swift in Sources */,
A10006 /* LoginView.swift in Sources */,
A10007 /* HomeView.swift in Sources */,
F2B322572F7F89B600368ED5 /* AssistantChatView.swift in Sources */,
A10008 /* HomeViewModel.swift in Sources */,
F2B3225B2F7F89DC00368ED5 /* AssistantModels.swift in Sources */,
A10009 /* FitnessModels.swift in Sources */,
A10010 /* FitnessAPI.swift in Sources */,
A10011 /* FitnessRepository.swift in Sources */,
A10012 /* FitnessTabView.swift in Sources */,
A10013 /* TodayView.swift in Sources */,
A10014 /* MealSectionView.swift in Sources */,
A10015 /* FoodSearchView.swift in Sources */,
A10016 /* AddFoodSheet.swift in Sources */,
A10017 /* HistoryView.swift in Sources */,
A10018 /* TemplatesView.swift in Sources */,
A10019 /* GoalsView.swift in Sources */,
A10020 /* EntryDetailView.swift in Sources */,
A10021 /* TodayViewModel.swift in Sources */,
A10022 /* FoodSearchViewModel.swift in Sources */,
A10023 /* HistoryViewModel.swift in Sources */,
A10024 /* TemplatesViewModel.swift in Sources */,
A10025 /* GoalsViewModel.swift in Sources */,
A10026 /* MacroRing.swift in Sources */,
A10027 /* MacroBar.swift in Sources */,
A10028 /* LoadingView.swift in Sources */,
A10029 /* Date+Extensions.swift in Sources */,
A10030 /* Color+Extensions.swift in Sources */,
F2B3225D2F7F89EF00368ED5 /* ImageCropView.swift in Sources */,
A10032 /* FoodLibraryView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
BC10001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
BC10002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
BC10003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CRN5A2VZ79;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
BC10004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CRN5A2VZ79;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CL10001 /* Build configuration list for PBXProject "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BC10001 /* Debug */,
BC10002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CL10002 /* Build configuration list for PBXNativeTarget "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BC10003 /* Debug */,
BC10004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = P10001 /* Project object */;
}

View File

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

View File

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

View File

@@ -1,15 +1,7 @@
import Foundation import Foundation
enum Config { enum Config {
static let gatewayURL: URL = { static let gatewayURL = "https://dash.quadjourney.com"
if let override = UserDefaults.standard.string(forKey: "gateway_url"), static let appName = "Platform"
let url = URL(string: override) { static let appVersion = "1.0.0"
return url
}
return URL(string: "https://dash.quadjourney.com")!
}()
static func apiURL(_ path: String) -> URL {
gatewayURL.appendingPathComponent(path)
}
} }

View File

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

View File

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

View File

@@ -1,29 +1,17 @@
import Foundation import Foundation
struct AuthUser: Codable { @Observable
let id: Int final class AuthManager {
let username: String var isLoggedIn = false
let displayName: String? var isCheckingAuth = true
var currentUser: GatewayUser?
var error: String?
enum CodingKeys: String, CodingKey { private let api = APIClient.shared
case id, username private let loggedInKey = "isLoggedIn"
case displayName = "display_name"
}
init(from decoder: Decoder) throws { init() {
let container = try decoder.container(keyedBy: CodingKeys.self) isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
// 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 { struct LoginRequest: Encodable {
@@ -33,26 +21,18 @@ struct LoginRequest: Encodable {
struct LoginResponse: Decodable { struct LoginResponse: Decodable {
let success: Bool let success: Bool
let user: AuthUser? let user: GatewayUser
} }
struct MeResponse: Decodable { struct AuthCheckResponse: Decodable {
let authenticated: Bool let authenticated: Bool
let user: AuthUser? let user: GatewayUser?
} }
@Observable struct GatewayUser: Decodable, Sendable {
final class AuthManager { let id: Int
var isLoggedIn = false let username: String
var isCheckingAuth = true let displayName: String
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 { func checkAuth() async {
@@ -62,9 +42,9 @@ final class AuthManager {
return return
} }
do { do {
let response: MeResponse = try await api.get("/api/auth/me") let response: AuthCheckResponse = try await api.get("/api/auth/me")
if response.authenticated { if response.authenticated, let user = response.user {
user = response.user currentUser = user
isLoggedIn = true isLoggedIn = true
} else { } else {
isLoggedIn = false isLoggedIn = false
@@ -78,27 +58,30 @@ final class AuthManager {
} }
func login(username: String, password: String) async { func login(username: String, password: String) async {
loginError = nil error = nil
do { do {
let response: LoginResponse = try await api.post("/api/auth/login", body: LoginRequest(username: username, password: password)) let response: LoginResponse = try await api.post(
"/api/auth/login",
body: LoginRequest(username: username, password: password)
)
if response.success { if response.success {
user = response.user currentUser = response.user
isLoggedIn = true isLoggedIn = true
UserDefaults.standard.set(true, forKey: loggedInKey) UserDefaults.standard.set(true, forKey: loggedInKey)
} else {
loginError = "Invalid credentials"
} }
} catch let error as APIError { } catch let apiError as APIError {
loginError = error.localizedDescription error = apiError.localizedDescription
} catch { } catch {
loginError = error.localizedDescription self.error = error.localizedDescription
} }
} }
func logout() { func logout() async {
struct LogoutResponse: Decodable { let success: Bool }
_ = try? await api.post("/api/auth/logout", body: [String: String]()) as LogoutResponse
api.clearCookies() api.clearCookies()
currentUser = nil
isLoggedIn = false isLoggedIn = false
user = nil
UserDefaults.standard.set(false, forKey: loggedInKey) UserDefaults.standard.set(false, forKey: loggedInKey)
} }
} }

View File

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

View File

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

View File

@@ -1,158 +1,73 @@
import SwiftUI import SwiftUI
struct LoginView: View { struct LoginView: View {
@Environment(AuthManager.self) private var authManager @Environment(AuthManager.self) private var auth
@State private var username = "" @State private var username = ""
@State private var password = "" @State private var password = ""
@State private var isLoading = false @State private var isLoading = false
@FocusState private var focusedField: Field?
private enum Field: Hashable {
case username, password
}
var body: some View { var body: some View {
ZStack {
Color.canvas
.ignoresSafeArea()
ScrollView {
VStack(spacing: 32) { VStack(spacing: 32) {
Spacer() Spacer()
.frame(height: 60)
// Logo / Branding
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "square.grid.2x2.fill") Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 48)) .font(.system(size: 48))
.foregroundStyle(Color.accentWarm) .foregroundStyle(Color.accentWarm)
Text("Platform") Text("Platform")
.font(.largeTitle.weight(.bold)) .font(.largeTitle.weight(.bold))
.foregroundStyle(Color.text1) .foregroundStyle(Color.textPrimary)
Text("Sign in to your account")
Text("Sign in to your dashboard")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.text3) .foregroundStyle(Color.textSecondary)
} }
// Form
VStack(spacing: 16) { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) { TextField("Username", text: $username)
Text("Username") .textFieldStyle(.roundedBorder)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
TextField("Enter username", text: $username)
.textFieldStyle(.plain)
.textContentType(.username) .textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($focusedField, equals: .username) .textInputAutocapitalization(.never)
.padding(14)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(
focusedField == .username ? Color.accentWarm : Color.black.opacity(0.06),
lineWidth: focusedField == .username ? 2 : 1
)
)
}
VStack(alignment: .leading, spacing: 6) { SecureField("Password", text: $password)
Text("Password") .textFieldStyle(.roundedBorder)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
SecureField("Enter password", text: $password)
.textFieldStyle(.plain)
.textContentType(.password) .textContentType(.password)
.focused($focusedField, equals: .password)
.padding(14)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(
focusedField == .password ? Color.accentWarm : Color.black.opacity(0.06),
lineWidth: focusedField == .password ? 2 : 1
)
)
}
if let error = authManager.loginError { if let error = auth.error {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(Color.error)
Text(error) Text(error)
.font(.subheadline) .font(.caption)
.foregroundStyle(Color.error) .foregroundStyle(.red)
}
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
} }
}
.padding(.horizontal, 4)
// Sign In Button
Button { Button {
performLogin()
} label: {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.controlSize(.small)
.tint(.white)
}
Text("Sign In")
.font(.body.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(canSubmit ? Color.accentWarm : Color.text4)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!canSubmit)
Spacer()
}
.padding(.horizontal, 28)
}
}
.onSubmit {
switch focusedField {
case .username:
focusedField = .password
case .password:
performLogin()
case .none:
break
}
}
}
private var canSubmit: Bool {
!username.trimmingCharacters(in: .whitespaces).isEmpty
&& !password.isEmpty
&& !isLoading
}
private func performLogin() {
guard canSubmit else { return }
isLoading = true isLoading = true
focusedField = nil
Task { Task {
await authManager.login( await auth.login(username: username, password: password)
username: username.trimmingCharacters(in: .whitespaces),
password: password
)
isLoading = false isLoading = false
} }
} label: {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text("Sign In")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Color.accentWarm)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
.disabled(username.isEmpty || password.isEmpty || isLoading)
.opacity(username.isEmpty || password.isEmpty ? 0.6 : 1)
}
.padding(.horizontal, 32)
Spacer()
Spacer()
}
.background(Color.canvas)
} }
} }

View File

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

View File

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

View File

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

@@ -1,108 +1,59 @@
import Foundation import Foundation
@MainActor @Observable @Observable
final class FoodSearchViewModel { final class FoodSearchViewModel {
var searchText = ""
var searchResults: [FoodItem] = []
var recentFoods: [FoodItem] = []
var isSearching = false
var isLoadingRecent = false
var errorMessage: String?
// Add food sheet state
var selectedFood: FoodItem?
var showAddSheet = false
var addQuantity: Double = 1
var addMealType: MealType = .guess()
var isAddingFood = false
private let repo = FitnessRepository.shared private let repo = FitnessRepository.shared
var searchText = ""
var searchResults: [Food] = []
var recentFoods: [RecentFood] = []
var allFoods: [Food] = []
var isSearching = false
var isLoadingInitial = false
var error: String?
private var searchTask: Task<Void, Never>? private var searchTask: Task<Void, Never>?
var displayedFoods: [FoodItem] { func loadInitial() async {
if searchText.trimmingCharacters(in: .whitespaces).isEmpty { isLoadingInitial = true
return recentFoods async let recentTask: () = loadRecent()
} async let allTask: () = loadAll()
return searchResults _ = await (recentTask, allTask)
isLoadingInitial = false
} }
var isShowingRecent: Bool { private func loadRecent() async {
searchText.trimmingCharacters(in: .whitespaces).isEmpty await repo.loadRecentFoods(limit: 20)
recentFoods = repo.recentFoods
} }
func loadRecent() async { private func loadAll() async {
isLoadingRecent = true await repo.loadFoods(limit: 200)
do { allFoods = repo.foods
recentFoods = try await repo.recentFoods(forceRefresh: true)
} catch {
// Silent failure for recent foods
}
isLoadingRecent = false
} }
func search() { func search() {
let query = searchText.trimmingCharacters(in: .whitespaces)
// Cancel previous search
searchTask?.cancel() searchTask?.cancel()
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { guard !query.isEmpty else {
searchResults = [] searchResults = []
isSearching = false
return return
} }
guard query.count >= 2 else {
return
}
isSearching = true isSearching = true
searchTask = Task { searchTask = Task {
// Debounce
try? await Task.sleep(for: .milliseconds(300)) try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
do { do {
let results = try await repo.searchFoods(query: query) let results = try await repo.searchFoods(query: query, limit: 20)
guard !Task.isCancelled else { return } if !Task.isCancelled {
searchResults = results searchResults = results
}
} catch { } catch {
guard !Task.isCancelled else { return } if !Task.isCancelled {
errorMessage = error.localizedDescription self.error = error.localizedDescription
}
} }
isSearching = false isSearching = false
} }
} }
func selectFood(_ food: FoodItem) {
selectedFood = food
addQuantity = 1
addMealType = .guess()
showAddSheet = true
}
func addFood(date: String, onComplete: @escaping () -> Void) async {
guard let food = selectedFood else { return }
isAddingFood = true
let request = CreateEntryRequest(
foodId: food.id,
quantity: addQuantity,
unit: food.baseUnit ?? "serving",
mealType: addMealType.rawValue,
entryDate: date,
entryMethod: "manual",
source: "ios_app"
)
do {
_ = try await repo.createEntry(request)
showAddSheet = false
selectedFood = nil
onComplete()
} catch {
errorMessage = "Failed to add food: \(error.localizedDescription)"
}
isAddingFood = false
}
} }

View File

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

View File

@@ -1,76 +0,0 @@
import Foundation
@MainActor @Observable
final class HistoryViewModel {
var days: [HistoryDay] = []
var isLoading = true
var errorMessage: String?
private let repo = FitnessRepository.shared
private let numberOfDays = 14
struct HistoryDay: Identifiable {
let date: Date
let dateString: String
let entries: [FoodEntry]
let goal: DailyGoal
var id: String { dateString }
var totalCalories: Double {
entries.reduce(0) { $0 + $1.calories }
}
var totalProtein: Double {
entries.reduce(0) { $0 + $1.protein }
}
var totalCarbs: Double {
entries.reduce(0) { $0 + $1.carbs }
}
var totalFat: Double {
entries.reduce(0) { $0 + $1.fat }
}
var entryCount: Int {
entries.count
}
var calorieProgress: Double {
guard goal.calories > 0 else { return 0 }
return min(totalCalories / goal.calories, 1.0)
}
}
func load() async {
isLoading = true
errorMessage = nil
var results: [HistoryDay] = []
do {
// Load past N days
for i in 0..<numberOfDays {
let date = Date().adding(days: -i)
let dateString = date.apiDateString
let entries = try await repo.entries(for: dateString, forceRefresh: i == 0)
let goal = try await repo.goals(for: dateString)
results.append(HistoryDay(
date: date,
dateString: dateString,
entries: entries,
goal: goal
))
}
days = results
} catch {
errorMessage = error.localizedDescription
days = results // Show what we have
}
isLoading = false
}
}

View File

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

View File

@@ -1,31 +1,44 @@
import Foundation import Foundation
@MainActor @Observable @Observable
final class TodayViewModel { final class TodayViewModel {
var entries: [FoodEntry] = []
var goal: DailyGoal = DailyGoal()
var selectedDate: Date = Date()
var isLoading = true
var errorMessage: String?
var expandedMeals: Set<String> = Set(MealType.allCases.map(\.rawValue))
private let repo = FitnessRepository.shared private let repo = FitnessRepository.shared
// MARK: - Computed Properties var selectedDate = Date()
var entries: [FoodEntry] = []
var goal: DailyGoal?
var isLoading = false
var error: String?
var dateString: String { var dateString: String {
selectedDate.apiDateString selectedDate.apiDateString
} }
var mealGroups: [MealGroup] { var isToday: Bool {
MealType.allCases.map { meal in Calendar.current.isDateInToday(selectedDate)
MealGroup( }
meal: meal,
entries: entries.filter { $0.mealType == meal } var displayDateString: String {
) if isToday { return "Today" }
if Calendar.current.isDateInYesterday(selectedDate) { return "Yesterday" }
if Calendar.current.isDateInTomorrow(selectedDate) { return "Tomorrow" }
return selectedDate.displayString
}
// MARK: - Grouped entries
var mealGroups: [(MealType, [FoodEntry])] {
let grouped = Dictionary(grouping: entries) { entry in
MealType(rawValue: entry.mealType) ?? .snack
}
return MealType.allCases.compactMap { meal in
guard let items = grouped[meal], !items.isEmpty else { return nil }
return (meal, items)
} }
} }
// MARK: - Totals
var totalCalories: Double { var totalCalories: Double {
entries.reduce(0) { $0 + $1.calories } entries.reduce(0) { $0 + $1.calories }
} }
@@ -42,70 +55,61 @@ final class TodayViewModel {
entries.reduce(0) { $0 + $1.fat } entries.reduce(0) { $0 + $1.fat }
} }
var caloriesRemaining: Double { var calorieGoal: Double {
max(goal.calories - totalCalories, 0) goal?.calories ?? 2000
}
var proteinGoal: Double {
goal?.protein ?? 150
}
var carbsGoal: Double {
goal?.carbs ?? 200
}
var fatGoal: Double {
goal?.fat ?? 65
} }
// MARK: - Actions // MARK: - Actions
func load() async { func load() async {
isLoading = true isLoading = true
errorMessage = nil error = nil
async let entriesTask: () = loadEntries()
do { async let goalTask: () = loadGoal()
async let entriesTask = repo.entries(for: dateString, forceRefresh: true) _ = await (entriesTask, goalTask)
async let goalsTask = repo.goals(for: dateString)
entries = try await entriesTask
goal = try await goalsTask
} catch {
errorMessage = error.localizedDescription
}
isLoading = false isLoading = false
} }
func goToNextDay() { func loadEntries() async {
selectedDate = selectedDate.adding(days: 1) await repo.loadEntries(date: dateString)
Task { await load() } entries = repo.entries
}
func loadGoal() async {
await repo.loadGoal(date: dateString)
goal = repo.goal
}
func deleteEntry(_ entry: FoodEntry) async {
do {
try await repo.deleteEntry(id: entry.id, date: dateString)
entries = repo.entries
} catch {
self.error = error.localizedDescription
}
} }
func goToPreviousDay() { func goToPreviousDay() {
selectedDate = selectedDate.adding(days: -1) selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
Task { await load() } }
func goToNextDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
} }
func goToToday() { func goToToday() {
selectedDate = Date() selectedDate = Date()
Task { await load() }
}
func toggleMeal(_ meal: String) {
if expandedMeals.contains(meal) {
expandedMeals.remove(meal)
} else {
expandedMeals.insert(meal)
}
}
func deleteEntry(_ entry: FoodEntry) async {
// Optimistic removal
entries.removeAll { $0.id == entry.id }
do {
try await repo.deleteEntry(id: entry.id, date: dateString)
} catch {
// Reload on failure
await load()
}
}
func updateEntryQuantity(id: String, quantity: Double) async {
let request = UpdateEntryRequest(quantity: quantity)
do {
_ = try await repo.updateEntry(id: id, request: request, date: dateString)
await load()
} catch {
errorMessage = "Failed to update entry"
}
} }
} }

View File

@@ -1,175 +1,109 @@
import SwiftUI import SwiftUI
struct AddFoodSheet: View { struct AddFoodSheet: View {
let food: FoodItem
@Binding var quantity: Double
@Binding var mealType: MealType
let isAdding: Bool
let onAdd: () -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var quantityText: String = "1" let food: Food
@State private var quantity: Double = 1.0
@State private var selectedMeal: MealType = .snack
@State private var selectedServingId: String?
@State private var entryDate = Date()
@State private var isAdding = false
@State private var error: String?
private var defaultServing: FoodServing? {
food.servings?.first(where: { $0.isDefault == 1 }) ?? food.servings?.first
}
private var multiplier: Double {
if let servingId = selectedServingId,
let serving = food.servings?.first(where: { $0.id == servingId }) {
return quantity * serving.amountInBase
}
return quantity
}
private var previewCalories: Double { food.caloriesPerBase * multiplier }
private var previewProtein: Double { food.proteinPerBase * multiplier }
private var previewCarbs: Double { food.carbsPerBase * multiplier }
private var previewFat: Double { food.fatPerBase * multiplier }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 24) { ScrollView {
// Food info header VStack(spacing: 20) {
foodHeader // Food name
VStack(spacing: 4) {
// Quantity input Text(food.name)
quantitySection .font(.title3.weight(.semibold))
.foregroundStyle(Color.textPrimary)
// Meal picker if let brand = food.brand, !brand.isEmpty {
mealPickerSection Text(brand)
.font(.subheadline)
// Macro preview .foregroundStyle(Color.textSecondary)
macroPreview
Spacer()
// Add button
Button(action: onAdd) {
HStack(spacing: 8) {
if isAdding {
ProgressView()
.controlSize(.small)
.tint(.white)
} }
Text("Add to \(mealType.displayName)") Text("Per \(food.baseUnit)")
.font(.body.weight(.semibold)) .font(.caption)
.foregroundStyle(Color.textTertiary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 16) .padding()
.background(Color.accentWarm)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(isAdding || quantity <= 0)
}
.padding(20)
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundStyle(Color.text3)
}
}
.onAppear {
quantityText = formatQuantity(quantity)
}
}
}
private var foodHeader: some View { // Quantity
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentWarmBg)
.frame(width: 48, height: 48)
Image(systemName: "fork.knife")
.foregroundStyle(Color.accentWarm)
}
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.headline)
.foregroundStyle(Color.text1)
.lineLimit(2)
Text("\(Int(food.caloriesPerBase)) kcal per \(food.displayUnit)")
.font(.caption)
.foregroundStyle(Color.text3)
}
Spacer()
}
}
private var quantitySection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Quantity (\(food.displayUnit))") Text("Quantity")
.font(.caption.weight(.semibold)) .font(.subheadline.weight(.medium))
.foregroundStyle(Color.text3) .foregroundStyle(Color.textSecondary)
.textCase(.uppercase)
HStack(spacing: 12) { HStack {
// Decrement
Button { Button {
adjustQuantity(by: -0.5) if quantity > 0.5 { quantity -= 0.5 }
} label: { } label: {
Image(systemName: "minus.circle.fill") Image(systemName: "minus.circle.fill")
.font(.title2) .font(.title2)
.foregroundStyle(quantity > 0.5 ? Color.accentWarm : Color.text4) .foregroundStyle(Color.accentWarm)
}
.disabled(quantity <= 0.5)
// Text field
TextField("1", text: $quantityText)
.textFieldStyle(.plain)
.keyboardType(.decimalPad)
.multilineTextAlignment(.center)
.font(.title2.weight(.bold))
.foregroundStyle(Color.text1)
.frame(width: 80)
.padding(.vertical, 8)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
)
.onChange(of: quantityText) {
if let val = Double(quantityText), val > 0 {
quantity = val
}
} }
// Increment Text(String(format: "%.1f", quantity))
.font(.title2.weight(.bold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
.frame(minWidth: 60)
Button { Button {
adjustQuantity(by: 0.5) quantity += 0.5
} label: { } label: {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
.font(.title2) .font(.title2)
.foregroundStyle(Color.accentWarm) .foregroundStyle(Color.accentWarm)
} }
}
.frame(maxWidth: .infinity)
Spacer() // Serving picker
if let servings = food.servings, !servings.isEmpty {
Picker("Serving", selection: $selectedServingId) {
Text("Base (\(food.baseUnit))").tag(nil as String?)
ForEach(servings) { serving in
Text(serving.name).tag(serving.id as String?)
}
}
.pickerStyle(.menu)
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Quick presets // Meal picker
ForEach([0.5, 1.0, 2.0], id: \.self) { preset in
Button {
quantity = preset
quantityText = formatQuantity(preset)
} label: {
Text(formatQuantity(preset))
.font(.caption.weight(.semibold))
.foregroundStyle(quantity == preset ? .white : Color.text2)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quantity == preset ? Color.accentWarm : Color.surfaceSecondary)
.clipShape(Capsule())
}
}
}
}
}
private var mealPickerSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Meal") Text("Meal")
.font(.caption.weight(.semibold)) .font(.subheadline.weight(.medium))
.foregroundStyle(Color.text3) .foregroundStyle(Color.textSecondary)
.textCase(.uppercase)
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(MealType.allCases) { meal in ForEach(MealType.allCases, id: \.rawValue) { meal in
Button { Button {
mealType = meal selectedMeal = meal
} label: { } label: {
VStack(spacing: 4) { VStack(spacing: 4) {
Image(systemName: meal.icon) Image(systemName: meal.icon)
@@ -177,63 +111,133 @@ struct AddFoodSheet: View {
Text(meal.displayName) Text(meal.displayName)
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
} }
.foregroundStyle(mealType == meal ? .white : Color.text2)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(selectedMeal == meal
mealType == meal ? Color.mealColor(for: meal.rawValue).opacity(0.15)
: Color.surfaceCard
)
.foregroundStyle(selectedMeal == meal
? Color.mealColor(for: meal.rawValue) ? Color.mealColor(for: meal.rawValue)
: Color.surfaceSecondary : Color.textSecondary
) )
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(selectedMeal == meal
? Color.mealColor(for: meal.rawValue).opacity(0.3)
: Color.clear, lineWidth: 1
)
)
} }
} }
} }
} }
} .padding()
.background(Color.surfaceCard)
private var macroPreview: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Nutrition Preview")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
HStack(spacing: 0) {
macroPreviewItem("Calories", value: food.scaledCalories(quantity: quantity), unit: "kcal", color: .caloriesColor)
Spacer()
macroPreviewItem("Protein", value: food.scaledProtein(quantity: quantity), unit: "g", color: .proteinColor)
Spacer()
macroPreviewItem("Carbs", value: food.scaledCarbs(quantity: quantity), unit: "g", color: .carbsColor)
Spacer()
macroPreviewItem("Fat", value: food.scaledFat(quantity: quantity), unit: "g", color: .fatColor)
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
// Nutrition preview
VStack(spacing: 8) {
Text("Nutrition Preview")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textSecondary)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
], spacing: 12) {
nutritionCell("Calories", value: previewCalories, unit: "kcal", color: .emerald)
nutritionCell("Protein", value: previewProtein, unit: "g", color: .macroProtein)
nutritionCell("Carbs", value: previewCarbs, unit: "g", color: .macroCarbs)
nutritionCell("Fat", value: previewFat, unit: "g", color: .macroFat)
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 12))
if let error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
// Add button
Button {
addEntry()
} label: {
if isAdding {
ProgressView()
.tint(.white)
} else {
Text("Add to \(selectedMeal.displayName)")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.accentWarm)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
.disabled(isAdding)
}
.padding()
}
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.onAppear {
if let ds = defaultServing {
selectedServingId = ds.id
}
} }
} }
private func macroPreviewItem(_ label: String, value: Double, unit: String, color: Color) -> some View { private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(Int(value))") Text("\(Int(value))")
.font(.system(.title3, design: .rounded, weight: .bold)) .font(.headline.monospacedDigit())
.foregroundStyle(color) .foregroundStyle(color)
Text(label) Text("\(unit)")
.font(.caption2) .font(.caption2)
.foregroundStyle(Color.text3) .foregroundStyle(Color.textTertiary)
Text(label)
.font(.caption2.weight(.medium))
.foregroundStyle(Color.textSecondary)
} }
.frame(maxWidth: .infinity)
} }
private func adjustQuantity(by amount: Double) { private func addEntry() {
quantity = max(0.5, quantity + amount) isAdding = true
quantityText = formatQuantity(quantity) error = nil
Task {
do {
let request = CreateEntryRequest(
foodId: food.id,
quantity: quantity,
unit: food.baseUnit,
mealType: selectedMeal.rawValue,
entryDate: entryDate.apiDateString,
entryMethod: "manual",
source: "ios",
servingId: selectedServingId
)
_ = try await FitnessRepository.shared.createEntry(request)
dismiss()
} catch {
self.error = error.localizedDescription
}
isAdding = false
} }
private func formatQuantity(_ qty: Double) -> String {
if qty == qty.rounded() {
return "\(Int(qty))"
}
return String(format: "%.1f", qty)
} }
} }

View File

@@ -3,256 +3,121 @@ import SwiftUI
struct EntryDetailView: View { struct EntryDetailView: View {
let entry: FoodEntry let entry: FoodEntry
let onDelete: () -> Void let onDelete: () -> Void
let onUpdateQuantity: (Double) -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var editQuantity: String @State private var showDeleteConfirmation = false
@State private var showDeleteConfirm = false
init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) {
self.entry = entry
self.onDelete = onDelete
self.onUpdateQuantity = onUpdateQuantity
_editQuantity = State(initialValue: entry.quantity == entry.quantity.rounded() ? "\(Int(entry.quantity))" : String(format: "%.1f", entry.quantity))
}
var body: some View { var body: some View {
NavigationStack {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
// Header // Header
entryHeader VStack(spacing: 6) {
Text(entry.foodName)
.font(.title2.weight(.bold))
.foregroundStyle(Color.textPrimary)
.multilineTextAlignment(.center)
// Quantity editor if let desc = entry.servingDescription {
quantityEditor Text(desc)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
// Macros grid HStack(spacing: 8) {
macrosGrid Image(systemName: (MealType(rawValue: entry.mealType) ?? .snack).icon)
.foregroundStyle(Color.mealColor(for: entry.mealType))
Text(entry.mealType.capitalized)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding()
// Details // Nutrition grid
detailsSection LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
], spacing: 16) {
nutritionCell("Calories", value: entry.calories, unit: "kcal", color: .emerald)
nutritionCell("Protein", value: entry.protein, unit: "g", color: .macroProtein)
nutritionCell("Carbs", value: entry.carbs, unit: "g", color: .macroCarbs)
nutritionCell("Fat", value: entry.fat, unit: "g", color: .macroFat)
nutritionCell("Sugar", value: entry.sugar, unit: "g", color: .orange)
nutritionCell("Fiber", value: entry.fiber, unit: "g", color: .green)
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
// Metadata
VStack(spacing: 8) {
metadataRow("Date", value: entry.entryDate)
metadataRow("Quantity", value: "\(String(format: "%.1f", entry.quantity)) \(entry.unit)")
metadataRow("Source", value: entry.source)
metadataRow("Method", value: entry.entryMethod)
if let note = entry.note, !note.isEmpty {
metadataRow("Note", value: note)
}
}
.padding()
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14))
// Delete button // Delete button
Button(role: .destructive) { Button(role: .destructive) {
showDeleteConfirm = true showDeleteConfirmation = true
} label: { } label: {
HStack(spacing: 8) { Label("Delete Entry", systemImage: "trash")
Image(systemName: "trash") .font(.headline)
Text("Delete Entry")
}
.font(.body.weight(.medium))
.foregroundStyle(Color.error)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 14) .frame(height: 48)
.background(Color.error.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12))
} }
.buttonStyle(.borderedProminent)
.tint(.red)
} }
.padding(20) .padding()
} }
.background(Color.canvas) .background(Color.canvas)
.navigationTitle("Entry Details") .navigationTitle("Entry Detail")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .alert("Delete Entry?", isPresented: $showDeleteConfirmation) {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.foregroundStyle(Color.accentWarm)
}
}
.confirmationDialog("Delete Entry", isPresented: $showDeleteConfirm) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
onDelete() onDelete()
dismiss() dismiss()
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} message: { } message: {
Text("Are you sure you want to delete \"\(entry.foodName)\"?") Text("This will permanently remove \(entry.foodName) from your log.")
}
}
}
private var entryHeader: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
.frame(width: 64, height: 64)
Image(systemName: Color.mealIcon(for: entry.mealType))
.font(.title2)
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
Text(entry.foodName)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.text1)
.multilineTextAlignment(.center)
Text(entry.mealType.capitalized)
.font(.caption.weight(.semibold))
.foregroundStyle(Color.mealColor(for: entry.mealType))
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color.mealColor(for: entry.mealType).opacity(0.1))
.clipShape(Capsule())
} }
} }
private var quantityEditor: some View { private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Quantity")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
HStack(spacing: 12) {
Button {
let current = Double(editQuantity) ?? 1
let newVal = max(0.5, current - 0.5)
editQuantity = formatQuantity(newVal)
} label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentWarm)
}
TextField("1", text: $editQuantity)
.textFieldStyle(.plain)
.keyboardType(.decimalPad)
.multilineTextAlignment(.center)
.font(.title2.weight(.bold))
.foregroundStyle(Color.text1)
.frame(width: 80)
.padding(.vertical, 8)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 10))
Button {
let current = Double(editQuantity) ?? 1
editQuantity = formatQuantity(current + 0.5)
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentWarm)
}
Spacer()
Button("Save") {
if let qty = Double(editQuantity), qty > 0 {
onUpdateQuantity(qty)
dismiss()
}
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentWarm)
.clipShape(Capsule())
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private var macrosGrid: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Nutrition")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 12) {
macroCell("Calories", value: entry.calories, unit: "kcal", color: .caloriesColor)
macroCell("Protein", value: entry.protein, unit: "g", color: .proteinColor)
macroCell("Carbs", value: entry.carbs, unit: "g", color: .carbsColor)
macroCell("Fat", value: entry.fat, unit: "g", color: .fatColor)
if let sugar = entry.sugar {
macroCell("Sugar", value: sugar, unit: "g", color: .sugarColor)
}
if let fiber = entry.fiber {
macroCell("Fiber", value: fiber, unit: "g", color: .fiberColor)
}
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private func macroCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(Int(value))") Text("\(Int(value))")
.font(.title3.weight(.bold)) .font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(color) .foregroundStyle(color)
Text(label) Text(unit)
.font(.caption2) .font(.caption2)
.foregroundStyle(Color.text3) .foregroundStyle(Color.textTertiary)
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 10) .padding(.vertical, 8)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
private var detailsSection: some View { private func metadataRow(_ label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Details")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
.textCase(.uppercase)
VStack(spacing: 0) {
detailRow("Serving", value: entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)")
if let method = entry.method, !method.isEmpty {
Divider()
detailRow("Method", value: method)
}
if let note = entry.note, !note.isEmpty {
Divider()
detailRow("Note", value: note)
}
if let loggedAt = entry.loggedAt, !loggedAt.isEmpty {
Divider()
detailRow("Logged", value: loggedAt)
}
}
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private func detailRow(_ label: String, value: String) -> some View {
HStack { HStack {
Text(label) Text(label)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.text3) .foregroundStyle(Color.textSecondary)
Spacer() Spacer()
Text(value) Text(value)
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1) .foregroundStyle(Color.textPrimary)
.lineLimit(2) }
.multilineTextAlignment(.trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func formatQuantity(_ qty: Double) -> String {
if qty == qty.rounded() {
return "\(Int(qty))"
}
return String(format: "%.1f", qty)
} }
} }

View File

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

View File

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

View File

@@ -1,115 +1,93 @@
import SwiftUI import SwiftUI
struct FoodSearchView: View { struct FoodSearchView: View {
let date: String var isSheet: Bool = false
let onFoodAdded: () -> Void @State private var vm = FoodSearchViewModel()
@State private var selectedFood: Food?
@Environment(\.dismiss) private var dismiss
@State private var viewModel = FoodSearchViewModel()
@FocusState private var searchFocused: Bool
var body: some View { var body: some View {
NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
// Search bar // Search bar
searchBar HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.textTertiary)
TextField("Search foods...", text: $vm.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if !vm.searchText.isEmpty {
Button {
vm.searchText = ""
vm.searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textTertiary)
}
}
}
.padding(12)
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.top, 8)
// Content if vm.isLoadingInitial {
if viewModel.isSearching || viewModel.isLoadingRecent { LoadingView()
LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...") } else if !vm.searchText.isEmpty {
} else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent { // Search results
searchResultsList
} else {
// Recent + All
defaultList
}
}
.background(Color.canvas)
.task {
await vm.loadInitial()
}
.onChange(of: vm.searchText) {
vm.search()
}
.sheet(item: $selectedFood) { food in
AddFoodSheet(food: food)
}
}
private var searchResultsList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if vm.isSearching {
ProgressView()
.padding()
} else if vm.searchResults.isEmpty {
EmptyStateView( EmptyStateView(
icon: "magnifyingglass", icon: "magnifyingglass",
title: "No results", title: "No results",
subtitle: "Try a different search term" subtitle: "Try a different search term"
) )
} else { } else {
foodList ForEach(vm.searchResults) { food in
foodRow(food)
} }
} }
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundStyle(Color.text3)
}
}
.sheet(isPresented: $viewModel.showAddSheet) {
if let food = viewModel.selectedFood {
AddFoodSheet(
food: food,
quantity: $viewModel.addQuantity,
mealType: $viewModel.addMealType,
isAdding: viewModel.isAddingFood
) {
Task {
await viewModel.addFood(date: date) {
onFoodAdded()
dismiss()
}
}
}
.presentationDetents([.medium])
}
}
.task {
await viewModel.loadRecent()
searchFocused = true
} }
} }
} }
private var searchBar: some View { private var defaultList: some View {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.text4)
TextField("Search foods...", text: $viewModel.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($searchFocused)
.onSubmit {
viewModel.search()
}
.onChange(of: viewModel.searchText) {
viewModel.search()
}
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
viewModel.searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.text4)
}
}
}
.padding(12)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private var foodList: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty { if !vm.recentFoods.isEmpty {
sectionHeader("Recent Foods") sectionHeader("Recent")
ForEach(vm.recentFoods) { recent in
recentFoodRow(recent)
}
} }
ForEach(viewModel.displayedFoods) { food in if !vm.allFoods.isEmpty {
FoodItemRow(food: food) { sectionHeader("All Foods")
viewModel.selectFood(food) ForEach(vm.allFoods) { food in
foodRow(food)
} }
Divider()
.padding(.leading, 60)
} }
} }
} }
@@ -118,81 +96,65 @@ struct FoodSearchView: View {
private func sectionHeader(_ title: String) -> some View { private func sectionHeader(_ title: String) -> some View {
Text(title) Text(title)
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(Color.text4) .foregroundStyle(Color.textTertiary)
.textCase(.uppercase) .textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20)
.padding(.horizontal, 16)
.padding(.top, 16) .padding(.top, 16)
.padding(.bottom, 8) .padding(.bottom, 4)
}
} }
struct FoodItemRow: View { private func foodRow(_ food: Food) -> some View {
let food: FoodItem Button {
let onTap: () -> Void selectedFood = food
} label: {
var body: some View { HStack {
Button(action: onTap) {
HStack(spacing: 12) {
// Icon
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.accentWarmBg)
.frame(width: 40, height: 40)
if let imageUrl = food.imageUrl, !imageUrl.isEmpty {
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 10))
} placeholder: {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.accentWarm)
}
} else {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.accentWarm)
}
}
// Info
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(food.name) Text(food.name)
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1) .foregroundStyle(Color.textPrimary)
.lineLimit(1) .lineLimit(1)
if let brand = food.brand, !brand.isEmpty {
Text(food.displayInfo) Text(brand)
.font(.caption) .font(.caption)
.foregroundStyle(Color.text3) .foregroundStyle(Color.textTertiary)
}
}
Spacer()
Text("\(Int(food.caloriesPerBase)) kcal/\(food.baseUnit)")
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
}
}
private func recentFoodRow(_ recent: RecentFood) -> some View {
Button {
// Navigate to add sheet by loading the full food
Task {
do {
let food = try await FitnessAPI().getFood(id: recent.foodId)
selectedFood = food
} catch {
// Silently fail
}
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(recent.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textPrimary)
.lineLimit(1) .lineLimit(1)
} }
Spacer() Spacer()
Text("\(Int(recent.caloriesPerBase)) kcal")
// Calories .font(.caption.weight(.medium))
VStack(alignment: .trailing, spacing: 2) { .foregroundStyle(Color.textSecondary)
Text("\(Int(food.caloriesPerBase))")
.font(.subheadline.weight(.bold))
.foregroundStyle(Color.text1)
Text("kcal")
.font(.caption2)
.foregroundStyle(Color.text4)
} }
.padding(.horizontal, 20)
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.text4)
}
.padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
.contentShape(Rectangle()) }
}
.buttonStyle(.plain)
} }
} }

View File

@@ -1,139 +1,84 @@
import SwiftUI import SwiftUI
struct GoalsView: View { struct GoalsView: View {
@State private var viewModel = GoalsViewModel() @State private var vm = GoalsViewModel()
var body: some View { var body: some View {
ScrollView { ScrollView {
if viewModel.isLoading { VStack(spacing: 16) {
LoadingView(message: "Loading goals...") if vm.isLoading {
.frame(height: 300) LoadingView()
} else if let goal = vm.goal {
goalCard(goal)
} else { } else {
VStack(spacing: 20) { EmptyStateView(
// Header icon: "target",
Text("Your daily targets") title: "No active goal",
.font(.headline) subtitle: vm.error ?? "Set goals from the web app"
.foregroundStyle(Color.text1)
.frame(maxWidth: .infinity, alignment: .leading)
// Goals cards
goalCard(
label: "Calories",
value: viewModel.goal.calories,
unit: "kcal",
icon: "flame.fill",
color: .caloriesColor
)
goalCard(
label: "Protein",
value: viewModel.goal.protein,
unit: "g",
icon: "circle.hexagonpath.fill",
color: .proteinColor
)
goalCard(
label: "Carbs",
value: viewModel.goal.carbs,
unit: "g",
icon: "bolt.fill",
color: .carbsColor
)
goalCard(
label: "Fat",
value: viewModel.goal.fat,
unit: "g",
icon: "drop.fill",
color: .fatColor
)
if let sugar = viewModel.goal.sugar, sugar > 0 {
goalCard(
label: "Sugar",
value: sugar,
unit: "g",
icon: "cube.fill",
color: .sugarColor
) )
} }
if let fiber = viewModel.goal.fiber, fiber > 0 { Spacer(minLength: 80)
goalCard(
label: "Fiber",
value: fiber,
unit: "g",
icon: "leaf.fill",
color: .fiberColor
)
} }
.padding(.horizontal)
// Info note
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundStyle(Color.text4)
Text("Goals can be updated from the web app")
.font(.caption)
.foregroundStyle(Color.text3)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8) .padding(.top, 8)
} }
.padding(16) .background(Color.canvas)
} .task {
await vm.load()
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(16)
}
} }
.refreshable { .refreshable {
await viewModel.load() await vm.load()
}
.task {
await viewModel.load()
} }
} }
private func goalCard(label: String, value: Double, unit: String, icon: String, color: Color) -> some View { private func goalCard(_ goal: DailyGoal) -> some View {
HStack(spacing: 16) { VStack(spacing: 16) {
ZStack { Text("Daily Goals")
RoundedRectangle(cornerRadius: 12) .font(.headline)
.fill(color.opacity(0.1)) .foregroundStyle(Color.textPrimary)
.frame(width: 48, height: 48) .frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: icon) LazyVGrid(columns: [
.font(.title3) GridItem(.flexible()),
.foregroundStyle(color) GridItem(.flexible()),
], spacing: 16) {
goalItem("Calories", value: goal.calories, unit: "kcal", color: .emerald)
goalItem("Protein", value: goal.protein, unit: "g", color: .macroProtein)
goalItem("Carbs", value: goal.carbs, unit: "g", color: .macroCarbs)
goalItem("Fat", value: goal.fat, unit: "g", color: .macroFat)
goalItem("Sugar", value: goal.sugar, unit: "g", color: .orange)
goalItem("Fiber", value: goal.fiber, unit: "g", color: .green)
} }
VStack(alignment: .leading, spacing: 2) { HStack {
Text(label) Text("Active since \(goal.startDate)")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.text2)
Text("Daily target")
.font(.caption) .font(.caption)
.foregroundStyle(Color.text4) .foregroundStyle(Color.textTertiary)
}
Spacer() Spacer()
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text("\(Int(value))")
.font(.title2.weight(.bold))
.foregroundStyle(Color.text1)
Text(unit)
.font(.subheadline)
.foregroundStyle(Color.text3)
} }
} }
.padding(16) .padding()
.background(Color.surface) .background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2) .shadow(color: .black.opacity(0.04), radius: 6, y: 2)
}
private func goalItem(_ label: String, value: Double, unit: String, color: Color) -> some View {
VStack(spacing: 6) {
Text("\(Int(value))")
.font(.title2.weight(.bold).monospacedDigit())
.foregroundStyle(color)
Text("\(unit)")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
} }

View File

@@ -1,159 +0,0 @@
import SwiftUI
struct HistoryView: View {
@State private var viewModel = HistoryViewModel()
var body: some View {
ScrollView {
if viewModel.isLoading {
LoadingView(message: "Loading history...")
.frame(height: 300)
} else if viewModel.days.isEmpty {
EmptyStateView(
icon: "calendar",
title: "No history",
subtitle: "Start logging food to see your history"
)
} else {
LazyVStack(spacing: 12) {
ForEach(viewModel.days) { day in
HistoryDayCard(day: day)
}
}
.padding(16)
}
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(16)
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
}
struct HistoryDayCard: View {
let day: HistoryViewModel.HistoryDay
@State private var isExpanded = false
var body: some View {
VStack(spacing: 0) {
// Header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 12) {
// Date
VStack(alignment: .leading, spacing: 2) {
Text(day.date.relativeLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.text1)
Text(day.date.shortDisplayString)
.font(.caption)
.foregroundStyle(Color.text3)
}
Spacer()
// Quick stats
HStack(spacing: 16) {
VStack(spacing: 2) {
Text("\(Int(day.totalCalories))")
.font(.subheadline.weight(.bold))
.foregroundStyle(Color.text1)
Text("kcal")
.font(.caption2)
.foregroundStyle(Color.text4)
}
// Mini progress ring
ZStack {
Circle()
.stroke(Color.caloriesColor.opacity(0.12), lineWidth: 3)
Circle()
.trim(from: 0, to: day.calorieProgress)
.stroke(Color.caloriesColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.frame(width: 28, height: 28)
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text4)
}
.padding(16)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 16)
// Macros row
HStack(spacing: 0) {
historyMacro("Protein", value: day.totalProtein, color: .proteinColor)
Spacer()
historyMacro("Carbs", value: day.totalCarbs, color: .carbsColor)
Spacer()
historyMacro("Fat", value: day.totalFat, color: .fatColor)
Spacer()
historyMacro("Entries", value: Double(day.entryCount), color: .text3, isCount: true)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
if !day.entries.isEmpty {
Divider()
.padding(.horizontal, 16)
// Entries list
ForEach(day.entries) { entry in
HStack(spacing: 8) {
Circle()
.fill(Color.mealColor(for: entry.mealType))
.frame(width: 6, height: 6)
Text(entry.foodName)
.font(.caption)
.foregroundStyle(Color.text2)
.lineLimit(1)
Spacer()
Text("\(Int(entry.calories)) kcal")
.font(.caption)
.foregroundStyle(Color.text3)
}
.padding(.horizontal, 20)
.padding(.vertical, 4)
}
.padding(.vertical, 4)
}
}
}
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
}
private func historyMacro(_ label: String, value: Double, color: Color, isCount: Bool = false) -> some View {
VStack(spacing: 2) {
Text("\(Int(value))\(isCount ? "" : "g")")
.font(.subheadline.weight(.semibold))
.foregroundStyle(color)
Text(label)
.font(.caption2)
.foregroundStyle(Color.text4)
}
}
}

View File

@@ -1,261 +1,112 @@
import SwiftUI import SwiftUI
struct MealSectionView: View { struct MealSectionView: View {
let group: MealGroup let mealType: MealType
let isExpanded: Bool let entries: [FoodEntry]
let onToggle: () -> Void
let onDelete: (FoodEntry) -> Void let onDelete: (FoodEntry) -> Void
let onAddFood: () -> Void
@State private var selectedEntry: FoodEntry? @State private var isExpanded = true
private var mealColor: Color { private var totalCalories: Double {
Color.mealColor(for: group.meal.rawValue) entries.reduce(0) { $0 + $1.calories }
} }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Header // Header
Button(action: onToggle) { Button {
HStack(spacing: 10) { withAnimation(.easeInOut(duration: 0.2)) {
Image(systemName: group.meal.icon) isExpanded.toggle()
.font(.body) }
.foregroundStyle(mealColor) } label: {
.frame(width: 28) HStack(spacing: 12) {
// Accent bar
RoundedRectangle(cornerRadius: 2)
.fill(Color.mealColor(for: mealType.rawValue))
.frame(width: 4, height: 32)
Text(group.meal.displayName) Image(systemName: mealType.icon)
.font(.subheadline.weight(.semibold)) .font(.body.weight(.medium))
.foregroundStyle(Color.text1) .foregroundStyle(Color.mealColor(for: mealType.rawValue))
if !group.entries.isEmpty { Text(mealType.displayName)
Text("\(group.entries.count)") .font(.headline)
.font(.caption2.weight(.bold)) .foregroundStyle(Color.textPrimary)
.foregroundStyle(mealColor)
Text("\(entries.count)")
.font(.caption.weight(.medium))
.foregroundStyle(Color.textSecondary)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(mealColor.opacity(0.1)) .background(Color.textSecondary.opacity(0.1))
.clipShape(Capsule()) .clipShape(Capsule())
}
Spacer() Spacer()
if !group.entries.isEmpty { Text("\(Int(totalCalories)) kcal")
Text("\(Int(group.totalCalories)) kcal") .font(.subheadline.weight(.semibold))
.font(.caption.weight(.semibold)) .foregroundStyle(Color.textSecondary)
.foregroundStyle(Color.text3)
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down") Image(systemName: "chevron.right")
.font(.caption.weight(.semibold)) .font(.caption.weight(.medium))
.foregroundStyle(Color.text4) .foregroundStyle(Color.textTertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 12)
} }
.buttonStyle(.plain)
// Entries
if isExpanded { if isExpanded {
if group.entries.isEmpty { ForEach(entries) { entry in
emptyMealView NavigationLink(destination: EntryDetailView(entry: entry, onDelete: {
} else { onDelete(entry)
Divider() })) {
.padding(.horizontal, 16) entryRow(entry)
ForEach(group.entries) { entry in
SwipeToDeleteRow(onDelete: { onDelete(entry) }) {
EntryRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture {
selectedEntry = entry
} }
} .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
if entry.id != group.entries.last?.id { onDelete(entry)
Divider() } label: {
.padding(.leading, 52) Label("Delete", systemImage: "trash")
} }
} }
} }
} }
} }
.background(Color.surface) .background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2) .shadow(color: .black.opacity(0.04), radius: 6, y: 2)
.sheet(item: $selectedEntry) { entry in .padding(.horizontal)
EntryDetailView(
entry: entry,
onDelete: { onDelete(entry) },
onUpdateQuantity: { _ in }
)
.presentationDetents([.large])
}
} }
private var emptyMealView: some View { private func entryRow(_ entry: FoodEntry) -> some View {
Button(action: onAddFood) {
HStack(spacing: 8) {
Image(systemName: "plus.circle")
.foregroundStyle(mealColor)
Text("Add food")
.font(.subheadline)
.foregroundStyle(Color.text3)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.buttonStyle(.plain)
}
}
// MARK: - Swipe to Delete Row
struct SwipeToDeleteRow<Content: View>: View {
let onDelete: () -> Void
@ViewBuilder let content: () -> Content
@State private var offset: CGFloat = 0
@State private var showDelete = false
private let deleteThreshold: CGFloat = -80
private let deleteWidth: CGFloat = 80
var body: some View {
ZStack(alignment: .trailing) {
// Delete background
HStack { HStack {
Spacer() VStack(alignment: .leading, spacing: 3) {
Button(action: {
withAnimation(.easeOut(duration: 0.2)) {
offset = -300
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
onDelete()
}
}) {
Image(systemName: "trash.fill")
.foregroundStyle(.white)
.frame(width: deleteWidth, height: .infinity)
}
.frame(width: deleteWidth)
.background(Color.error)
}
.opacity(offset < 0 ? 1 : 0)
// Content
content()
.offset(x: offset)
.gesture(
DragGesture(minimumDistance: 20)
.onChanged { value in
let translation = value.translation.width
if translation < 0 {
offset = translation
}
}
.onEnded { value in
withAnimation(.easeOut(duration: 0.2)) {
if offset < deleteThreshold {
offset = -deleteWidth
showDelete = true
} else {
offset = 0
showDelete = false
}
}
}
)
.onTapGesture {
if showDelete {
withAnimation(.easeOut(duration: 0.2)) {
offset = 0
showDelete = false
}
}
}
}
.clipped()
}
}
// MARK: - Entry Row
struct EntryRow: View {
let entry: FoodEntry
var body: some View {
HStack(spacing: 12) {
// Food icon or image
ZStack {
Circle()
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
.frame(width: 36, height: 36)
if let imageUrl = entry.imageUrl, !imageUrl.isEmpty {
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFill()
.frame(width: 36, height: 36)
.clipShape(Circle())
} placeholder: {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
} else {
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(Color.mealColor(for: entry.mealType))
}
}
// Name and serving
VStack(alignment: .leading, spacing: 2) {
Text(entry.foodName) Text(entry.foodName)
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundStyle(Color.text1) .foregroundStyle(Color.textPrimary)
.lineLimit(1) .lineLimit(1)
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)") if let desc = entry.servingDescription ?? entry.snapshotServingLabel {
Text(desc)
.font(.caption) .font(.caption)
.foregroundStyle(Color.text3) .foregroundStyle(Color.textTertiary)
.lineLimit(1) .lineLimit(1)
} }
}
Spacer() Spacer()
// Macros Text("\(Int(entry.calories)) kcal")
VStack(alignment: .trailing, spacing: 2) { .font(.subheadline.weight(.medium))
Text("\(Int(entry.calories))") .foregroundStyle(Color.textSecondary)
.font(.subheadline.weight(.bold))
.foregroundStyle(Color.text1)
+ Text(" kcal")
.font(.caption2)
.foregroundStyle(Color.text3)
HStack(spacing: 6) { Image(systemName: "chevron.right")
macroTag("P", value: entry.protein, color: .proteinColor) .font(.caption2)
macroTag("C", value: entry.carbs, color: .carbsColor) .foregroundStyle(Color.textTertiary)
macroTag("F", value: entry.fat, color: .fatColor)
}
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(Color.surface) .background(Color.surfaceCard)
}
private func macroTag(_ label: String, value: Double, color: Color) -> some View {
Text("\(label)\(Int(value))")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(color)
}
private func formatQuantity(_ qty: Double) -> String {
if qty == qty.rounded() {
return "\(Int(qty))"
}
return String(format: "%.1f", qty)
} }
} }

View File

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

View File

@@ -1,171 +1,124 @@
import SwiftUI import SwiftUI
struct TodayView: View { struct TodayView: View {
@Bindable var viewModel: TodayViewModel @State private var vm = TodayViewModel()
@Binding var showFoodSearch: Bool
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
// Date selector // Date selector
dateSelector dateSelector
if viewModel.isLoading { // Macro summary
LoadingView(message: "Loading entries...") macroSummary
.frame(height: 200)
} else {
// Macro summary card
macroSummaryCard
// Meal sections // Meal sections
ForEach(viewModel.mealGroups) { group in if vm.entries.isEmpty && !vm.isLoading {
EmptyStateView(
icon: "fork.knife",
title: "No entries yet",
subtitle: "Tap + to log your first meal"
)
} else {
ForEach(vm.mealGroups, id: \.0) { mealType, entries in
MealSectionView( MealSectionView(
group: group, mealType: mealType,
isExpanded: viewModel.expandedMeals.contains(group.meal.rawValue), entries: entries,
onToggle: { viewModel.toggleMeal(group.meal.rawValue) },
onDelete: { entry in onDelete: { entry in
Task { await viewModel.deleteEntry(entry) } Task { await vm.deleteEntry(entry) }
},
onAddFood: {
showFoodSearch = true
} }
) )
} }
// Bottom spacing for FAB
Spacer()
.frame(height: 80)
} }
if let error = viewModel.errorMessage { Spacer(minLength: 80)
ErrorBanner(message: error) {
Task { await viewModel.load() }
} }
.padding(.horizontal, 4) .padding(.top, 8)
}
}
.padding(16)
} }
.refreshable { .refreshable {
await viewModel.load() await vm.load()
}
// Floating add button
addButton
} }
.background(Color.canvas)
.task { .task {
await viewModel.load() await vm.load()
}
.onChange(of: vm.selectedDate) {
Task { await vm.load() }
} }
} }
// MARK: - Date Selector
private var dateSelector: some View { private var dateSelector: some View {
HStack(spacing: 0) { HStack {
Button { Button {
viewModel.goToPreviousDay() vm.goToPreviousDay()
} label: { } label: {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.body.weight(.semibold)) .font(.title3.weight(.medium))
.foregroundStyle(Color.accentWarm) .foregroundStyle(Color.accentWarm)
.frame(width: 44, height: 44)
}
Spacer()
VStack(spacing: 2) {
Text(viewModel.selectedDate.relativeLabel)
.font(.headline)
.foregroundStyle(Color.text1)
if !viewModel.selectedDate.isToday {
Text(viewModel.selectedDate.displayString)
.font(.caption)
.foregroundStyle(Color.text3)
}
}
.onTapGesture {
viewModel.goToToday()
} }
Spacer() Spacer()
Button { Button {
viewModel.goToNextDay() vm.goToToday()
} label: {
Text(vm.displayDateString)
.font(.headline)
.foregroundStyle(Color.textPrimary)
}
Spacer()
Button {
vm.goToNextDay()
} label: { } label: {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.body.weight(.semibold)) .font(.title3.weight(.medium))
.foregroundStyle( .foregroundStyle(Color.accentWarm)
viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm
)
.frame(width: 44, height: 44)
} }
.disabled(viewModel.selectedDate.isToday)
} }
.padding(.horizontal, 4) .padding(.horizontal, 20)
.padding(.vertical, 4) .padding(.vertical, 8)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
} }
// MARK: - Macro Summary private var macroSummary: some View {
private var macroSummaryCard: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
// Calories ring HStack(spacing: 24) {
HStack(spacing: 20) { MacroRingWithLabel(
MacroRingLarge( consumed: vm.totalCalories,
current: viewModel.totalCalories, goal: vm.calorieGoal,
goal: viewModel.goal.calories, label: "kcal",
color: .caloriesColor, color: .emerald,
size: 100, size: 90,
lineWidth: 9 lineWidth: 9
) )
VStack(alignment: .leading, spacing: 10) { VStack(spacing: 10) {
macroRow("Protein", current: viewModel.totalProtein, goal: viewModel.goal.protein, color: .proteinColor) MacroBar(
macroRow("Carbs", current: viewModel.totalCarbs, goal: viewModel.goal.carbs, color: .carbsColor) label: "Protein",
macroRow("Fat", current: viewModel.totalFat, goal: viewModel.goal.fat, color: .fatColor) consumed: vm.totalProtein,
} goal: vm.proteinGoal,
.frame(maxWidth: .infinity) color: .macroProtein
)
MacroBar(
label: "Carbs",
consumed: vm.totalCarbs,
goal: vm.carbsGoal,
color: .macroCarbs
)
MacroBar(
label: "Fat",
consumed: vm.totalFat,
goal: vm.fatGoal,
color: .macroFat
)
} }
} }
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
} }
.padding(16)
private func macroRow(_ label: String, current: Double, goal: Double, color: Color) -> some View { .background(Color.surfaceCard)
VStack(spacing: 4) { .clipShape(RoundedRectangle(cornerRadius: 14))
HStack { .shadow(color: .black.opacity(0.04), radius: 6, y: 2)
Text(label) .padding(.horizontal)
.font(.caption.weight(.medium))
.foregroundStyle(Color.text3)
Spacer()
Text("\(Int(current))/\(Int(goal))g")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.text2)
}
MacroBarCompact(current: current, goal: goal, color: color)
}
}
// MARK: - Add Button
private var addButton: some View {
Button {
showFoodSearch = true
} label: {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentWarm)
.clipShape(Circle())
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
}
.padding(20)
} }
} }

View File

@@ -1,188 +1,122 @@
import SwiftUI import SwiftUI
import PhotosUI
struct HomeView: View { struct HomeView: View {
@Environment(AuthManager.self) private var authManager @Environment(AuthManager.self) private var auth
@State private var viewModel = HomeViewModel() @State private var vm = HomeViewModel()
@State private var showProfileMenu = false
var body: some View { var body: some View {
NavigationStack { ZStack {
ScrollView { // Background
VStack(spacing: 20) { if let bg = vm.backgroundImage {
if viewModel.isLoading { Image(uiImage: bg)
LoadingView(message: "Loading dashboard...") .resizable()
.frame(height: 300) .aspectRatio(contentMode: .fill)
.ignoresSafeArea()
} else { } else {
// Quick Stats Card Color.canvas.ignoresSafeArea()
caloriesSummaryCard
// Macros Card
macrosCard
// Quick Actions
quickActionsCard
} }
if let error = viewModel.errorMessage { ScrollView {
ErrorBanner(message: error) { VStack(spacing: 16) {
Task { await viewModel.load() } // Top bar
} HStack {
} Text("Home")
} .font(.largeTitle.weight(.bold))
.padding(16) .foregroundStyle(vm.hasBackground ? .white : Color.textPrimary)
} Spacer()
.background(Color.canvas)
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu { Menu {
PhotosPicker(
selection: $vm.selectedPhoto,
matching: .images
) {
Label("Change Background", systemImage: "photo")
}
if vm.hasBackground {
Button(role: .destructive) { Button(role: .destructive) {
authManager.logout() vm.removeBackground()
} label: {
Label("Remove Background", systemImage: "trash")
}
}
Divider()
Button(role: .destructive) {
Task { await auth.logout() }
} label: { } label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
} }
} label: { } label: {
Image(systemName: "person.circle.fill") Image(systemName: "person.circle.fill")
.font(.title3)
.foregroundStyle(Color.accentWarm)
}
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
}
private var caloriesSummaryCard: some View {
VStack(spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Today")
.font(.headline)
.foregroundStyle(Color.text1)
Text(Date().displayString)
.font(.subheadline)
.foregroundStyle(Color.text3)
}
Spacer()
Text("\(viewModel.entryCount) entries")
.font(.caption)
.foregroundStyle(Color.text4)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.surfaceSecondary)
.clipShape(Capsule())
}
MacroRingLarge(
current: viewModel.totalCalories,
goal: viewModel.goal.calories,
color: .caloriesColor,
size: 140,
lineWidth: 12
)
HStack(spacing: 0) {
macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal")
Spacer()
macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal")
Spacer()
macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal")
}
}
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
private var macrosCard: some View {
VStack(spacing: 14) {
Text("Macros")
.font(.headline)
.foregroundStyle(Color.text1)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 20) {
MacroRing(
current: viewModel.totalProtein,
goal: viewModel.goal.protein,
color: .proteinColor,
label: "Protein",
unit: "g",
size: 68
)
MacroRing(
current: viewModel.totalCarbs,
goal: viewModel.goal.carbs,
color: .carbsColor,
label: "Carbs",
unit: "g",
size: 68
)
MacroRing(
current: viewModel.totalFat,
goal: viewModel.goal.fat,
color: .fatColor,
label: "Fat",
unit: "g",
size: 68
)
}
.frame(maxWidth: .infinity)
}
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
private var quickActionsCard: some View {
VStack(spacing: 12) {
Text("Quick Actions")
.font(.headline)
.foregroundStyle(Color.text1)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 12) {
quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald)
quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor)
quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm)
}
}
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
private func macroStat(_ label: String, value: Int, unit: String) -> some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.system(.title3, design: .rounded, weight: .bold))
.foregroundStyle(Color.text1)
Text("\(label)")
.font(.caption2)
.foregroundStyle(Color.text4)
}
}
private func quickActionButton(icon: String, label: String, color: Color) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2) .font(.title2)
.foregroundStyle(color) .foregroundStyle(vm.hasBackground ? .white : Color.accentWarm)
Text(label) }
}
.padding(.horizontal)
.padding(.top, 60)
// Widget grid
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
], spacing: 12) {
// Calorie widget
calorieWidget
.gridCellColumns(2)
}
.padding(.horizontal)
Spacer(minLength: 100)
}
}
}
.navigationBarHidden(true)
.task {
await vm.loadTodayData()
}
.onChange(of: vm.selectedPhoto) {
Task { await vm.handlePhotoSelection() }
}
}
private var calorieWidget: some View {
HStack(spacing: 20) {
MacroRingWithLabel(
consumed: vm.totalCalories,
goal: vm.calorieGoal,
label: "kcal",
color: .emerald,
size: 100,
lineWidth: 10
)
VStack(alignment: .leading, spacing: 8) {
Text("Calories")
.font(.headline)
.foregroundStyle(vm.hasBackground ? .white : Color.textPrimary)
Text("\(Int(vm.totalCalories)) / \(Int(vm.calorieGoal))")
.font(.subheadline)
.foregroundStyle(vm.hasBackground ? .white.opacity(0.8) : Color.textSecondary)
let remaining = max(vm.calorieGoal - vm.totalCalories, 0)
Text("\(Int(remaining)) remaining")
.font(.caption) .font(.caption)
.fontWeight(.medium) .foregroundStyle(vm.hasBackground ? .white.opacity(0.6) : Color.textTertiary)
.foregroundStyle(Color.text2) }
Spacer()
}
.padding(20)
.background {
if vm.hasBackground {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(Color.surfaceCard)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12))
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,21 @@
import SwiftUI import SwiftUI
struct MacroRing: View { struct MacroRing: View {
let current: Double let consumed: Double
let goal: Double let goal: Double
let color: Color var lineWidth: CGFloat = 10
let label: String var color: Color = .emerald
let unit: String var size: CGFloat = 100
var size: CGFloat = 72
var lineWidth: CGFloat = 7
private var progress: Double { private var progress: Double {
guard goal > 0 else { return 0 } guard goal > 0 else { return 0 }
return min(current / goal, 1.0) return min(max(consumed / goal, 0), 1.0)
}
private var remaining: Double {
max(goal - current, 0)
} }
var body: some View { var body: some View {
VStack(spacing: 4) {
ZStack { ZStack {
Circle() Circle()
.stroke(color.opacity(0.12), lineWidth: lineWidth) .stroke(color.opacity(0.15), lineWidth: lineWidth)
Circle() Circle()
.trim(from: 0, to: progress) .trim(from: 0, to: progress)
@@ -31,66 +24,38 @@ struct MacroRing: View {
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.5), value: progress) .animation(.easeInOut(duration: 0.6), value: progress)
VStack(spacing: 0) {
Text("\(Int(remaining))")
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
.foregroundStyle(Color.text1)
Text("left")
.font(.system(size: size * 0.13, weight: .medium))
.foregroundStyle(Color.text4)
}
} }
.frame(width: size, height: size) .frame(width: size, height: size)
Text(label)
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(Color.text3)
}
} }
} }
struct MacroRingLarge: View { struct MacroRingWithLabel: View {
let current: Double let consumed: Double
let goal: Double let goal: Double
let color: Color let label: String
var size: CGFloat = 120 var color: Color = .emerald
var size: CGFloat = 100
var lineWidth: CGFloat = 10 var lineWidth: CGFloat = 10
private var progress: Double {
guard goal > 0 else { return 0 }
return min(current / goal, 1.0)
}
private var remaining: Double {
max(goal - current, 0)
}
var body: some View { var body: some View {
ZStack { ZStack {
Circle() MacroRing(
.stroke(color.opacity(0.12), lineWidth: lineWidth) consumed: consumed,
goal: goal,
Circle() lineWidth: lineWidth,
.trim(from: 0, to: progress) color: color,
.stroke( size: size
color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
) )
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.5), value: progress)
VStack(spacing: 2) { VStack(spacing: 2) {
Text("\(Int(remaining))") Text("\(Int(consumed))")
.font(.system(size: size * 0.26, weight: .bold, design: .rounded)) .font(.system(size: size * 0.22, weight: .bold, design: .rounded))
.foregroundStyle(Color.text1) .foregroundStyle(Color.textPrimary)
Text("remaining") Text(label)
.font(.system(size: size * 0.11, weight: .medium)) .font(.system(size: size * 0.1, weight: .medium))
.foregroundStyle(Color.text4) .foregroundStyle(Color.textSecondary)
} }
} }
.frame(width: size, height: size)
} }
} }

View File

@@ -1,98 +1,40 @@
import SwiftUI import SwiftUI
extension Color { extension Color {
// Warm palette matching web app // MARK: - Canvas / Background
static let canvas = Color(hex: "F5EFE6") static let canvas = Color(red: 0.96, green: 0.94, blue: 0.90) // #F5EFE6
static let surface = Color.white
static let surfaceSecondary = Color(hex: "F4F4F5")
static let cardBackground = Color.white
static let cardSecondary = Color(hex: "F4F4F5")
static let text1 = Color(hex: "18181B") // MARK: - Accent
static let text2 = Color(hex: "3F3F46") static let accentWarm = Color(red: 0.545, green: 0.412, blue: 0.078) // #8B6914
static let text3 = Color(hex: "71717A") static let emerald = Color(red: 0.020, green: 0.588, blue: 0.412) // #059669
static let text4 = Color(hex: "A1A1AA")
// Accent warm amber/brown // MARK: - Surfaces
static let accentWarm = Color(hex: "8B6914") static let surfaceCard = Color.white
static let accentWarmBg = Color(hex: "FEF7E6") static let surfaceSheet = Color(red: 0.98, green: 0.97, blue: 0.95)
// Emerald accent from web // MARK: - Text
static let accentEmerald = Color(hex: "059669") static let textPrimary = Color(red: 0.12, green: 0.12, blue: 0.12)
static let accentEmeraldBg = Color(hex: "ECFDF5") static let textSecondary = Color(red: 0.45, green: 0.45, blue: 0.45)
static let textTertiary = Color(red: 0.65, green: 0.65, blue: 0.65)
// Semantic // MARK: - Meal Colors
static let success = Color(hex: "059669") static let mealBreakfast = Color(red: 1.0, green: 0.72, blue: 0.27) // warm orange
static let error = Color(hex: "DC2626") static let mealLunch = Color(red: 0.30, green: 0.69, blue: 0.31) // green
static let warning = Color(hex: "D97706") static let mealDinner = Color(red: 0.40, green: 0.35, blue: 0.80) // purple
static let mealSnack = Color(red: 0.93, green: 0.46, blue: 0.46) // coral
// Macro colors // MARK: - Macros
static let caloriesColor = Color(hex: "8B6914") static let macroProtein = Color(red: 0.35, green: 0.56, blue: 0.91) // blue
static let proteinColor = Color(hex: "059669") static let macroCarbs = Color(red: 0.96, green: 0.65, blue: 0.14) // amber
static let carbsColor = Color(hex: "3B82F6") static let macroFat = Color(red: 0.85, green: 0.35, blue: 0.45) // pink-red
static let fatColor = Color(hex: "F59E0B")
static let sugarColor = Color(hex: "EC4899")
static let fiberColor = Color(hex: "8B5CF6")
// Meal colors static func mealColor(for mealType: String) -> Color {
static let breakfast = Color(hex: "F59E0B") switch mealType.lowercased() {
static let lunch = Color(hex: "059669") case "breakfast": return .mealBreakfast
static let dinner = Color(hex: "3B82F6") case "lunch": return .mealLunch
static let snack = Color(hex: "8B5CF6") case "dinner": return .mealDinner
static let breakfastColor = Color(hex: "F59E0B") case "snack": return .mealSnack
static let lunchColor = Color(hex: "059669") default: return .accentWarm
static let dinnerColor = Color(hex: "3B82F6")
static let snackColor = Color(hex: "8B5CF6")
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3:
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8:
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
static func mealColor(for meal: String) -> Color {
switch meal.lowercased() {
case "breakfast": return .breakfast
case "lunch": return .lunch
case "dinner": return .dinner
case "snack": return .snack
default: return .text3
} }
} }
static func mealColor(for meal: MealType) -> Color {
mealColor(for: meal.rawValue)
}
static func mealIcon(for meal: String) -> String {
switch meal.lowercased() {
case "breakfast": return "sunrise.fill"
case "lunch": return "sun.max.fill"
case "dinner": return "moon.fill"
case "snack": return "leaf.fill"
default: return "fork.knife"
}
}
static func mealIcon(for meal: MealType) -> String {
mealIcon(for: meal.rawValue)
}
} }

View File

@@ -1,58 +1,28 @@
import Foundation import Foundation
extension Date { extension Date {
/// Format as yyyy-MM-dd for API calls private static let apiFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static let displayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE, MMM d"
return f
}()
var apiDateString: String { var apiDateString: String {
let formatter = DateFormatter() Self.apiFormatter.string(from: self)
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.string(from: self)
} }
/// Display format: "Mon, Apr 2"
var displayString: String { var displayString: String {
let formatter = DateFormatter() Self.displayFormatter.string(from: self)
formatter.dateFormat = "EEE, MMM d"
return formatter.string(from: self)
} }
/// Full display: "Monday, April 2, 2026" static func fromAPI(_ string: String) -> Date? {
var fullDisplayString: String { apiFormatter.date(from: string)
let formatter = DateFormatter()
formatter.dateStyle = .full
return formatter.string(from: self)
}
/// Short display: "Apr 2"
var shortDisplayString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: self)
}
var isToday: Bool {
Calendar.current.isDateInToday(self)
}
var isYesterday: Bool {
Calendar.current.isDateInYesterday(self)
}
func adding(days: Int) -> Date {
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
}
static func from(apiString: String) -> Date? {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.date(from: apiString)
}
/// Returns a label like "Today", "Yesterday", or the display string
var relativeLabel: String {
if isToday { return "Today" }
if isYesterday { return "Yesterday" }
return displayString
} }
} }

View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN adduser --disabled-password --no-create-home appuser
COPY --chown=appuser app/ app/
EXPOSE 8400
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8400/api/health', timeout=3)" || exit 1
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8400"]

View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN adduser --disabled-password --no-create-home appuser
COPY --chown=appuser app/ app/
ENV PYTHONUNBUFFERED=1
USER appuser
CMD ["python", "-m", "app.worker.tasks"]

View File

View File

View File

@@ -0,0 +1,21 @@
"""API dependencies — auth, database session."""
from fastapi import Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
async def get_user_id(
x_gateway_user_id: str = Header(None, alias="X-Gateway-User-Id"),
) -> str:
"""Extract authenticated user ID from gateway-injected header."""
if not x_gateway_user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
return x_gateway_user_id
async def get_db_session() -> AsyncSession:
"""Provide an async database session."""
async for session in get_db():
yield session

View File

@@ -0,0 +1,232 @@
"""Episode listing, detail, and streaming endpoints."""
from __future__ import annotations
import os
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from fastapi.responses import StreamingResponse, Response
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.requests import Request
from app.api.deps import get_user_id, get_db_session
from app.config import CONTENT_TYPES
from app.models import Episode, Show, Progress
router = APIRouter(prefix="/api/episodes", tags=["episodes"])
# ── Named convenience endpoints (must be before /{episode_id}) ──
@router.get("/recent")
async def recent_episodes(
limit: int = Query(20, ge=1, le=100),
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Recently played episodes ordered by last_played_at."""
stmt = (
select(Episode, Show, Progress)
.join(Progress, Progress.episode_id == Episode.id)
.join(Show, Show.id == Episode.show_id)
.where(Progress.user_id == user_id)
.order_by(Progress.last_played_at.desc())
.limit(limit)
)
result = await db.execute(stmt)
return [_format(ep, show, prog) for ep, show, prog in result.all()]
@router.get("/in-progress")
async def in_progress_episodes(
limit: int = Query(20, ge=1, le=100),
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Episodes with progress > 0 and not completed."""
stmt = (
select(Episode, Show, Progress)
.join(Progress, Progress.episode_id == Episode.id)
.join(Show, Show.id == Episode.show_id)
.where(
Progress.user_id == user_id,
Progress.position_seconds > 0,
Progress.is_completed == False, # noqa: E712
)
.order_by(Progress.last_played_at.desc())
.limit(limit)
)
result = await db.execute(stmt)
return [_format(ep, show, prog) for ep, show, prog in result.all()]
# ── List ──
@router.get("")
async def list_episodes(
show_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""List episodes with optional filters: show_id, status (unplayed|in_progress|completed)."""
stmt = (
select(Episode, Show, Progress)
.join(Show, Show.id == Episode.show_id)
.outerjoin(Progress, and_(Progress.episode_id == Episode.id, Progress.user_id == user_id))
.where(Episode.user_id == user_id)
)
if show_id:
stmt = stmt.where(Episode.show_id == uuid.UUID(show_id))
if status == "unplayed":
stmt = stmt.where(Progress.id == None) # noqa: E711
elif status == "in_progress":
stmt = stmt.where(Progress.position_seconds > 0, Progress.is_completed == False) # noqa: E712
elif status == "completed":
stmt = stmt.where(Progress.is_completed == True) # noqa: E712
stmt = stmt.order_by(Episode.published_at.desc().nullslast()).offset(offset).limit(limit)
result = await db.execute(stmt)
return [_format(ep, show, prog) for ep, show, prog in result.all()]
# ── Detail ──
@router.get("/{episode_id}")
async def get_episode(
episode_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Episode detail with progress."""
stmt = (
select(Episode, Show, Progress)
.join(Show, Show.id == Episode.show_id)
.outerjoin(Progress, and_(Progress.episode_id == Episode.id, Progress.user_id == user_id))
.where(Episode.id == uuid.UUID(episode_id), Episode.user_id == user_id)
)
row = (await db.execute(stmt)).first()
if not row:
raise HTTPException(404, "Episode not found")
ep, show, prog = row
return _format(ep, show, prog)
# ── Stream local audio ──
@router.get("/{episode_id}/stream")
async def stream_episode(
episode_id: str,
request: Request,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Stream local audio file with HTTP Range support for seeking."""
ep = await db.get(Episode, uuid.UUID(episode_id))
if not ep or ep.user_id != user_id:
raise HTTPException(404, "Episode not found")
show = await db.get(Show, ep.show_id)
if not show or show.show_type != "local":
raise HTTPException(400, "Streaming only available for local episodes")
file_path = ep.audio_url
if not file_path or not os.path.isfile(file_path):
raise HTTPException(404, "Audio file not found")
file_size = os.path.getsize(file_path)
ext = os.path.splitext(file_path)[1].lower()
content_type = CONTENT_TYPES.get(ext, "application/octet-stream")
range_header = request.headers.get("range")
return _range_response(file_path, file_size, content_type, range_header)
# ── Helpers ──
def _format(ep: Episode, show: Show, prog: Optional[Progress]) -> dict:
return {
"id": str(ep.id),
"show_id": str(ep.show_id),
"title": ep.title,
"description": ep.description,
"audio_url": ep.audio_url,
"duration_seconds": ep.duration_seconds,
"file_size_bytes": ep.file_size_bytes,
"published_at": ep.published_at.isoformat() if ep.published_at else None,
"artwork_url": ep.artwork_url or show.artwork_url,
"show": {
"id": str(show.id),
"title": show.title,
"author": show.author,
"artwork_url": show.artwork_url,
"show_type": show.show_type,
},
"progress": {
"position_seconds": prog.position_seconds,
"duration_seconds": prog.duration_seconds,
"is_completed": prog.is_completed,
"playback_speed": prog.playback_speed,
"last_played_at": prog.last_played_at.isoformat() if prog.last_played_at else None,
} if prog else None,
}
def _range_response(file_path: str, file_size: int, content_type: str, range_header: Optional[str]):
"""Build a streaming response with optional Range support for seeking."""
if range_header and range_header.startswith("bytes="):
range_spec = range_header[6:]
parts = range_spec.split("-")
start = int(parts[0]) if parts[0] else 0
end = int(parts[1]) if len(parts) > 1 and parts[1] else file_size - 1
end = min(end, file_size - 1)
if start >= file_size:
return Response(status_code=416, headers={"Content-Range": f"bytes */{file_size}"})
length = end - start + 1
def iter_range():
with open(file_path, "rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
return StreamingResponse(
iter_range(),
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(length),
"Accept-Ranges": "bytes",
},
)
def iter_file():
with open(file_path, "rb") as f:
while chunk := f.read(65536):
yield chunk
return StreamingResponse(
iter_file(),
media_type=content_type,
headers={
"Content-Length": str(file_size),
"Accept-Ranges": "bytes",
},
)

View File

@@ -0,0 +1,229 @@
"""Playback control endpoints — play, pause, seek, complete, now-playing, speed."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_user_id, get_db_session
from app.models import Episode, Show, Progress, PlaybackEvent
router = APIRouter(prefix="/api/playback", tags=["playback"])
# ── Schemas ──
class PlayRequest(BaseModel):
episode_id: str
position_seconds: float = 0
class PauseRequest(BaseModel):
episode_id: str
position_seconds: float
class SeekRequest(BaseModel):
episode_id: str
position_seconds: float
class CompleteRequest(BaseModel):
episode_id: str
class SpeedRequest(BaseModel):
speed: float
# ── Helpers ──
async def _get_or_create_progress(
db: AsyncSession, user_id: str, episode_id: uuid.UUID
) -> Progress:
"""Get existing progress or create a new one."""
stmt = select(Progress).where(
Progress.user_id == user_id,
Progress.episode_id == episode_id,
)
prog = (await db.execute(stmt)).scalar_one_or_none()
if not prog:
ep = await db.get(Episode, episode_id)
prog = Progress(
user_id=user_id,
episode_id=episode_id,
duration_seconds=ep.duration_seconds if ep else None,
)
db.add(prog)
await db.flush()
return prog
async def _log_event(
db: AsyncSession, user_id: str, episode_id: uuid.UUID,
event_type: str, position: float, speed: float = 1.0,
):
db.add(PlaybackEvent(
user_id=user_id,
episode_id=episode_id,
event_type=event_type,
position_seconds=position,
playback_speed=speed,
))
async def _validate_episode(db: AsyncSession, user_id: str, episode_id_str: str) -> Episode:
eid = uuid.UUID(episode_id_str)
ep = await db.get(Episode, eid)
if not ep or ep.user_id != user_id:
raise HTTPException(404, "Episode not found")
return ep
# ── Endpoints ──
@router.post("/play")
async def play(
body: PlayRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Set episode as currently playing."""
ep = await _validate_episode(db, user_id, body.episode_id)
prog = await _get_or_create_progress(db, user_id, ep.id)
prog.position_seconds = body.position_seconds
prog.last_played_at = datetime.utcnow()
prog.is_completed = False
await _log_event(db, user_id, ep.id, "play", body.position_seconds, prog.playback_speed)
await db.commit()
return {"status": "playing", "position_seconds": prog.position_seconds}
@router.post("/pause")
async def pause(
body: PauseRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Pause and save position."""
ep = await _validate_episode(db, user_id, body.episode_id)
prog = await _get_or_create_progress(db, user_id, ep.id)
prog.position_seconds = body.position_seconds
prog.last_played_at = datetime.utcnow()
await _log_event(db, user_id, ep.id, "pause", body.position_seconds, prog.playback_speed)
await db.commit()
return {"status": "paused", "position_seconds": prog.position_seconds}
@router.post("/seek")
async def seek(
body: SeekRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Seek to position."""
ep = await _validate_episode(db, user_id, body.episode_id)
prog = await _get_or_create_progress(db, user_id, ep.id)
prog.position_seconds = body.position_seconds
prog.last_played_at = datetime.utcnow()
await _log_event(db, user_id, ep.id, "seek", body.position_seconds, prog.playback_speed)
await db.commit()
return {"status": "seeked", "position_seconds": prog.position_seconds}
@router.post("/complete")
async def complete(
body: CompleteRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Mark episode as completed."""
ep = await _validate_episode(db, user_id, body.episode_id)
prog = await _get_or_create_progress(db, user_id, ep.id)
prog.is_completed = True
prog.position_seconds = prog.duration_seconds or prog.position_seconds
prog.last_played_at = datetime.utcnow()
await _log_event(db, user_id, ep.id, "complete", prog.position_seconds, prog.playback_speed)
await db.commit()
return {"status": "completed"}
@router.get("/now-playing")
async def now_playing(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Get the most recently played episode with progress."""
stmt = (
select(Episode, Show, Progress)
.join(Progress, Progress.episode_id == Episode.id)
.join(Show, Show.id == Episode.show_id)
.where(
Progress.user_id == user_id,
Progress.is_completed == False, # noqa: E712
)
.order_by(Progress.last_played_at.desc())
.limit(1)
)
row = (await db.execute(stmt)).first()
if not row:
return None
ep, show, prog = row
return {
"episode": {
"id": str(ep.id),
"title": ep.title,
"audio_url": ep.audio_url,
"duration_seconds": ep.duration_seconds,
"artwork_url": ep.artwork_url or show.artwork_url,
},
"show": {
"id": str(show.id),
"title": show.title,
"author": show.author,
"artwork_url": show.artwork_url,
"show_type": show.show_type,
},
"progress": {
"position_seconds": prog.position_seconds,
"duration_seconds": prog.duration_seconds,
"is_completed": prog.is_completed,
"playback_speed": prog.playback_speed,
"last_played_at": prog.last_played_at.isoformat() if prog.last_played_at else None,
},
}
@router.post("/speed")
async def set_speed(
body: SpeedRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Update playback speed for all user's active progress records."""
if body.speed < 0.5 or body.speed > 3.0:
raise HTTPException(400, "Speed must be between 0.5 and 3.0")
# Update the most recent (now-playing) progress record's speed
stmt = (
select(Progress)
.where(
Progress.user_id == user_id,
Progress.is_completed == False, # noqa: E712
)
.order_by(Progress.last_played_at.desc())
.limit(1)
)
prog = (await db.execute(stmt)).scalar_one_or_none()
if prog:
prog.playback_speed = body.speed
await db.commit()
return {"speed": body.speed}

View File

@@ -0,0 +1,236 @@
"""Queue management endpoints."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func, delete, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_user_id, get_db_session
from app.models import Episode, Show, QueueItem, Progress, PlaybackEvent
router = APIRouter(prefix="/api/queue", tags=["queue"])
# ── Schemas ──
class QueueAddRequest(BaseModel):
episode_id: str
class QueueReorderRequest(BaseModel):
episode_ids: List[str]
# ── Endpoints ──
@router.get("")
async def get_queue(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Get user's queue ordered by sort_order, with episode and show info."""
stmt = (
select(QueueItem, Episode, Show)
.join(Episode, Episode.id == QueueItem.episode_id)
.join(Show, Show.id == Episode.show_id)
.where(QueueItem.user_id == user_id)
.order_by(QueueItem.sort_order)
)
result = await db.execute(stmt)
rows = result.all()
return [
{
"queue_id": str(qi.id),
"sort_order": qi.sort_order,
"added_at": qi.added_at.isoformat() if qi.added_at else None,
"episode": {
"id": str(ep.id),
"title": ep.title,
"audio_url": ep.audio_url,
"duration_seconds": ep.duration_seconds,
"artwork_url": ep.artwork_url or show.artwork_url,
"published_at": ep.published_at.isoformat() if ep.published_at else None,
},
"show": {
"id": str(show.id),
"title": show.title,
"author": show.author,
"artwork_url": show.artwork_url,
},
}
for qi, ep, show in rows
]
@router.post("/add", status_code=201)
async def add_to_queue(
body: QueueAddRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Add episode to end of queue."""
eid = uuid.UUID(body.episode_id)
ep = await db.get(Episode, eid)
if not ep or ep.user_id != user_id:
raise HTTPException(404, "Episode not found")
# Check if already in queue
existing = await db.execute(
select(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
)
if existing.scalar_one_or_none():
raise HTTPException(409, "Episode already in queue")
# Get max sort_order
max_order = await db.scalar(
select(func.coalesce(func.max(QueueItem.sort_order), -1)).where(QueueItem.user_id == user_id)
)
qi = QueueItem(
user_id=user_id,
episode_id=eid,
sort_order=max_order + 1,
)
db.add(qi)
await db.commit()
return {"status": "added", "sort_order": qi.sort_order}
@router.post("/play-next", status_code=201)
async def play_next(
body: QueueAddRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Insert episode at position 1 (after currently playing)."""
eid = uuid.UUID(body.episode_id)
ep = await db.get(Episode, eid)
if not ep or ep.user_id != user_id:
raise HTTPException(404, "Episode not found")
# Remove if already in queue
await db.execute(
delete(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
)
# Shift all items at position >= 1 up by 1
stmt = (
select(QueueItem)
.where(QueueItem.user_id == user_id, QueueItem.sort_order >= 1)
.order_by(QueueItem.sort_order.desc())
)
items = (await db.execute(stmt)).scalars().all()
for item in items:
item.sort_order += 1
qi = QueueItem(user_id=user_id, episode_id=eid, sort_order=1)
db.add(qi)
await db.commit()
return {"status": "added", "sort_order": 1}
@router.post("/play-now", status_code=201)
async def play_now(
body: QueueAddRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Insert at position 0 and start playing."""
eid = uuid.UUID(body.episode_id)
ep = await db.get(Episode, eid)
if not ep or ep.user_id != user_id:
raise HTTPException(404, "Episode not found")
# Remove if already in queue
await db.execute(
delete(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
)
# Shift everything up
stmt = (
select(QueueItem)
.where(QueueItem.user_id == user_id)
.order_by(QueueItem.sort_order.desc())
)
items = (await db.execute(stmt)).scalars().all()
for item in items:
item.sort_order += 1
qi = QueueItem(user_id=user_id, episode_id=eid, sort_order=0)
db.add(qi)
# Also create/update progress and log play event
prog_stmt = select(Progress).where(Progress.user_id == user_id, Progress.episode_id == eid)
prog = (await db.execute(prog_stmt)).scalar_one_or_none()
if not prog:
prog = Progress(
user_id=user_id,
episode_id=eid,
duration_seconds=ep.duration_seconds,
)
db.add(prog)
prog.last_played_at = datetime.utcnow()
prog.is_completed = False
db.add(PlaybackEvent(
user_id=user_id,
episode_id=eid,
event_type="play",
position_seconds=prog.position_seconds or 0,
playback_speed=prog.playback_speed,
))
await db.commit()
return {"status": "playing", "sort_order": 0}
@router.delete("/{episode_id}", status_code=204)
async def remove_from_queue(
episode_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Remove episode from queue."""
eid = uuid.UUID(episode_id)
result = await db.execute(
delete(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
)
if result.rowcount == 0:
raise HTTPException(404, "Episode not in queue")
await db.commit()
@router.post("/reorder")
async def reorder_queue(
body: QueueReorderRequest,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Reorder queue by providing episode IDs in desired order."""
for i, eid_str in enumerate(body.episode_ids):
eid = uuid.UUID(eid_str)
stmt = select(QueueItem).where(QueueItem.user_id == user_id, QueueItem.episode_id == eid)
qi = (await db.execute(stmt)).scalar_one_or_none()
if qi:
qi.sort_order = i
await db.commit()
return {"status": "reordered", "count": len(body.episode_ids)}
@router.delete("")
async def clear_queue(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Clear entire queue."""
await db.execute(delete(QueueItem).where(QueueItem.user_id == user_id))
await db.commit()
return {"status": "cleared"}

View File

@@ -0,0 +1,519 @@
"""Show CRUD endpoints."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import Optional
import feedparser
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_user_id, get_db_session
from app.models import Show, Episode, Progress
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/shows", tags=["shows"])
# ── Schemas ──
class ShowCreate(BaseModel):
feed_url: Optional[str] = None
local_path: Optional[str] = None
title: Optional[str] = None
class ShowOut(BaseModel):
id: str
title: str
author: Optional[str] = None
description: Optional[str] = None
artwork_url: Optional[str] = None
feed_url: Optional[str] = None
local_path: Optional[str] = None
show_type: str
episode_count: int = 0
unplayed_count: int = 0
created_at: Optional[str] = None
updated_at: Optional[str] = None
# ── Helpers ──
def _parse_duration(value: str) -> Optional[int]:
"""Parse HH:MM:SS or MM:SS or seconds string to integer seconds."""
if not value:
return None
parts = value.strip().split(":")
try:
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
elif len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
else:
return int(float(parts[0]))
except (ValueError, IndexError):
return None
async def _fetch_and_parse_feed(feed_url: str, etag: str = None, last_modified: str = None):
"""Fetch RSS feed and parse with feedparser."""
headers = {}
if etag:
headers["If-None-Match"] = etag
if last_modified:
headers["If-Modified-Since"] = last_modified
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(feed_url, headers=headers)
if resp.status_code == 304:
return None, None, None # Not modified
resp.raise_for_status()
feed = feedparser.parse(resp.text)
new_etag = resp.headers.get("ETag")
new_last_modified = resp.headers.get("Last-Modified")
return feed, new_etag, new_last_modified
def _extract_show_info(feed) -> dict:
"""Extract show metadata from parsed feed."""
f = feed.feed
artwork = None
if hasattr(f, "image") and f.image:
artwork = getattr(f.image, "href", None)
if not artwork and hasattr(f, "itunes_image"):
artwork = f.get("itunes_image", {}).get("href") if isinstance(f.get("itunes_image"), dict) else None
# Try another common location
if not artwork:
for link in getattr(f, "links", []):
if link.get("rel") == "icon" or link.get("type", "").startswith("image/"):
artwork = link.get("href")
break
return {
"title": getattr(f, "title", "Unknown Show"),
"author": getattr(f, "author", None) or getattr(f, "itunes_author", None),
"description": getattr(f, "summary", None) or getattr(f, "subtitle", None),
"artwork_url": artwork,
}
def _extract_episodes(feed, show_id: uuid.UUID, user_id: str) -> list[dict]:
"""Extract episodes from parsed feed."""
episodes = []
for entry in feed.entries:
audio_url = None
file_size = None
for enc in getattr(entry, "enclosures", []):
if enc.get("type", "").startswith("audio/") or enc.get("href", "").split("?")[0].endswith(
(".mp3", ".m4a", ".ogg", ".opus")
):
audio_url = enc.get("href")
file_size = int(enc.get("length", 0)) or None
break
# Fallback: check links
if not audio_url:
for link in getattr(entry, "links", []):
if link.get("type", "").startswith("audio/"):
audio_url = link.get("href")
file_size = int(link.get("length", 0)) or None
break
if not audio_url:
continue # Skip entries without audio
# Duration
duration = None
itunes_duration = getattr(entry, "itunes_duration", None)
if itunes_duration:
duration = _parse_duration(str(itunes_duration))
# Published date
published = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
from time import mktime
published = datetime.fromtimestamp(mktime(entry.published_parsed))
except (TypeError, ValueError, OverflowError):
pass
# GUID
guid = getattr(entry, "id", None) or audio_url
# Episode artwork
ep_artwork = None
itunes_image = getattr(entry, "itunes_image", None)
if itunes_image and isinstance(itunes_image, dict):
ep_artwork = itunes_image.get("href")
episodes.append({
"id": uuid.uuid4(),
"show_id": show_id,
"user_id": user_id,
"title": getattr(entry, "title", None),
"description": getattr(entry, "summary", None),
"audio_url": audio_url,
"duration_seconds": duration,
"file_size_bytes": file_size,
"published_at": published,
"guid": guid,
"artwork_url": ep_artwork,
})
return episodes
async def _scan_local_folder(local_path: str, show_id: uuid.UUID, user_id: str) -> list[dict]:
"""Scan a local folder for audio files and create episode dicts."""
import os
from mutagen import File as MutagenFile
from app.config import AUDIO_EXTENSIONS
episodes = []
if not os.path.isdir(local_path):
return episodes
files = sorted(os.listdir(local_path))
for i, fname in enumerate(files):
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
fpath = os.path.join(local_path, fname)
if not os.path.isfile(fpath):
continue
# Read metadata with mutagen
title = os.path.splitext(fname)[0]
duration = None
file_size = os.path.getsize(fpath)
try:
audio = MutagenFile(fpath)
if audio and audio.info:
duration = int(audio.info.length)
# Try to get title from tags
if audio and audio.tags:
for tag_key in ("title", "TIT2", "\xa9nam"):
tag_val = audio.tags.get(tag_key)
if tag_val:
title = str(tag_val[0]) if isinstance(tag_val, list) else str(tag_val)
break
except Exception:
pass
stat = os.stat(fpath)
published = datetime.fromtimestamp(stat.st_mtime)
episodes.append({
"id": uuid.uuid4(),
"show_id": show_id,
"user_id": user_id,
"title": title,
"description": None,
"audio_url": fpath,
"duration_seconds": duration,
"file_size_bytes": file_size,
"published_at": published,
"guid": f"local:{fpath}",
"artwork_url": None,
})
return episodes
# ── Endpoints ──
@router.get("")
async def list_shows(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""List user's shows with episode counts and unplayed counts."""
# Subquery: total episodes per show
ep_count_sq = (
select(
Episode.show_id,
func.count(Episode.id).label("episode_count"),
)
.where(Episode.user_id == user_id)
.group_by(Episode.show_id)
.subquery()
)
# Subquery: episodes with completed progress
played_sq = (
select(
Episode.show_id,
func.count(Progress.id).label("played_count"),
)
.join(Progress, Progress.episode_id == Episode.id)
.where(Episode.user_id == user_id, Progress.is_completed == True) # noqa: E712
.group_by(Episode.show_id)
.subquery()
)
stmt = (
select(
Show,
func.coalesce(ep_count_sq.c.episode_count, 0).label("episode_count"),
func.coalesce(played_sq.c.played_count, 0).label("played_count"),
)
.outerjoin(ep_count_sq, ep_count_sq.c.show_id == Show.id)
.outerjoin(played_sq, played_sq.c.show_id == Show.id)
.where(Show.user_id == user_id)
.order_by(Show.title)
)
result = await db.execute(stmt)
rows = result.all()
return [
{
"id": str(show.id),
"title": show.title,
"author": show.author,
"description": show.description,
"artwork_url": show.artwork_url,
"feed_url": show.feed_url,
"local_path": show.local_path,
"show_type": show.show_type,
"episode_count": ep_count,
"unplayed_count": ep_count - played_count,
"created_at": show.created_at.isoformat() if show.created_at else None,
"updated_at": show.updated_at.isoformat() if show.updated_at else None,
}
for show, ep_count, played_count in rows
]
@router.post("", status_code=201)
async def create_show(
body: ShowCreate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Create a show from RSS feed or local folder."""
if not body.feed_url and not body.local_path:
raise HTTPException(400, "Either feed_url or local_path is required")
show_id = uuid.uuid4()
if body.feed_url:
# RSS podcast
try:
feed, etag, last_modified = await _fetch_and_parse_feed(body.feed_url)
except Exception as e:
log.error("Failed to fetch feed %s: %s", body.feed_url, e)
raise HTTPException(400, f"Failed to fetch feed: {e}")
if feed is None:
raise HTTPException(400, "Feed returned no content")
info = _extract_show_info(feed)
show = Show(
id=show_id,
user_id=user_id,
title=body.title or info["title"],
author=info["author"],
description=info["description"],
artwork_url=info["artwork_url"],
feed_url=body.feed_url,
show_type="podcast",
etag=etag,
last_modified=last_modified,
last_fetched_at=datetime.utcnow(),
)
db.add(show)
await db.flush()
ep_dicts = _extract_episodes(feed, show_id, user_id)
for ep_dict in ep_dicts:
db.add(Episode(**ep_dict))
await db.commit()
await db.refresh(show)
return {
"id": str(show.id),
"title": show.title,
"show_type": show.show_type,
"episode_count": len(ep_dicts),
}
else:
# Local folder
if not body.title:
raise HTTPException(400, "title is required for local shows")
show = Show(
id=show_id,
user_id=user_id,
title=body.title,
local_path=body.local_path,
show_type="local",
last_fetched_at=datetime.utcnow(),
)
db.add(show)
await db.flush()
ep_dicts = await _scan_local_folder(body.local_path, show_id, user_id)
for ep_dict in ep_dicts:
db.add(Episode(**ep_dict))
await db.commit()
await db.refresh(show)
return {
"id": str(show.id),
"title": show.title,
"show_type": show.show_type,
"episode_count": len(ep_dicts),
}
@router.get("/{show_id}")
async def get_show(
show_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Get show details with episodes."""
show = await db.get(Show, uuid.UUID(show_id))
if not show or show.user_id != user_id:
raise HTTPException(404, "Show not found")
# Fetch episodes with progress
stmt = (
select(Episode, Progress)
.outerjoin(Progress, (Progress.episode_id == Episode.id) & (Progress.user_id == user_id))
.where(Episode.show_id == show.id)
.order_by(Episode.published_at.desc().nullslast())
)
result = await db.execute(stmt)
rows = result.all()
episodes = []
for ep, prog in rows:
episodes.append({
"id": str(ep.id),
"title": ep.title,
"description": ep.description,
"audio_url": ep.audio_url,
"duration_seconds": ep.duration_seconds,
"file_size_bytes": ep.file_size_bytes,
"published_at": ep.published_at.isoformat() if ep.published_at else None,
"artwork_url": ep.artwork_url,
"progress": {
"position_seconds": prog.position_seconds,
"is_completed": prog.is_completed,
"playback_speed": prog.playback_speed,
"last_played_at": prog.last_played_at.isoformat() if prog.last_played_at else None,
} if prog else None,
})
return {
"id": str(show.id),
"title": show.title,
"author": show.author,
"description": show.description,
"artwork_url": show.artwork_url,
"feed_url": show.feed_url,
"local_path": show.local_path,
"show_type": show.show_type,
"last_fetched_at": show.last_fetched_at.isoformat() if show.last_fetched_at else None,
"created_at": show.created_at.isoformat() if show.created_at else None,
"episodes": episodes,
}
@router.delete("/{show_id}", status_code=204)
async def delete_show(
show_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Delete a show and all its episodes."""
show = await db.get(Show, uuid.UUID(show_id))
if not show or show.user_id != user_id:
raise HTTPException(404, "Show not found")
await db.delete(show)
await db.commit()
@router.post("/{show_id}/refresh")
async def refresh_show(
show_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Re-fetch RSS feed or re-scan local folder for new episodes."""
show = await db.get(Show, uuid.UUID(show_id))
if not show or show.user_id != user_id:
raise HTTPException(404, "Show not found")
new_count = 0
if show.show_type == "podcast" and show.feed_url:
try:
feed, etag, last_modified = await _fetch_and_parse_feed(
show.feed_url, show.etag, show.last_modified
)
except Exception as e:
raise HTTPException(400, f"Failed to fetch feed: {e}")
if feed is None:
return {"new_episodes": 0, "message": "Feed not modified"}
info = _extract_show_info(feed)
show.title = info["title"] or show.title
show.author = info["author"] or show.author
show.description = info["description"] or show.description
show.artwork_url = info["artwork_url"] or show.artwork_url
show.etag = etag
show.last_modified = last_modified
show.last_fetched_at = datetime.utcnow()
ep_dicts = _extract_episodes(feed, show.id, user_id)
# Get existing guids
existing = await db.execute(
select(Episode.guid).where(Episode.show_id == show.id)
)
existing_guids = {row[0] for row in existing.all()}
for ep_dict in ep_dicts:
if ep_dict["guid"] not in existing_guids:
db.add(Episode(**ep_dict))
new_count += 1
elif show.show_type == "local" and show.local_path:
ep_dicts = await _scan_local_folder(show.local_path, show.id, user_id)
existing = await db.execute(
select(Episode.guid).where(Episode.show_id == show.id)
)
existing_guids = {row[0] for row in existing.all()}
for ep_dict in ep_dicts:
if ep_dict["guid"] not in existing_guids:
db.add(Episode(**ep_dict))
new_count += 1
show.last_fetched_at = datetime.utcnow()
await db.commit()
return {"new_episodes": new_count}

View File

@@ -0,0 +1,35 @@
"""Media service configuration — all from environment variables."""
import os
# ── Database ──
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://brain:brain@brain-db:5432/brain",
)
DATABASE_URL_SYNC = DATABASE_URL.replace("+asyncpg", "")
# ── Redis ──
REDIS_URL = os.environ.get("REDIS_URL", "redis://brain-redis:6379/0")
# ── Local audio ──
LOCAL_AUDIO_PATH = os.environ.get("LOCAL_AUDIO_PATH", "/audiobooks")
# ── Worker ──
FEED_FETCH_INTERVAL = int(os.environ.get("FEED_FETCH_INTERVAL", "1800"))
# ── Service ──
PORT = int(os.environ.get("PORT", "8400"))
DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true")
# ── Audio extensions ──
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".ogg", ".opus", ".flac"}
# ── Content types ──
CONTENT_TYPES = {
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".opus": "audio/opus",
".flac": "audio/flac",
}

View File

@@ -0,0 +1,18 @@
"""Database session and engine setup."""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import DATABASE_URL
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=10, max_overflow=5)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session

View File

@@ -0,0 +1,87 @@
"""Media service — FastAPI entrypoint."""
import logging
from fastapi import FastAPI, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import DEBUG, PORT
logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
app = FastAPI(
title="Media Service",
description="Podcast and local audio management with playback tracking.",
version="1.0.0",
docs_url="/api/docs" if DEBUG else None,
redoc_url=None,
)
@app.on_event("startup")
async def startup():
from app.database import engine, Base
from app.models import Show, Episode, Progress, QueueItem, PlaybackEvent # noqa
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logging.getLogger(__name__).info("Media service started on port %s", PORT)
# Register routers
from app.api.shows import router as shows_router
from app.api.episodes import router as episodes_router
from app.api.playback import router as playback_router
from app.api.queue import router as queue_router
app.include_router(shows_router)
app.include_router(episodes_router)
app.include_router(playback_router)
app.include_router(queue_router)
@app.get("/api/health")
async def health():
return {"status": "ok", "service": "media"}
from app.api.deps import get_user_id, get_db_session
@app.get("/api/stats")
async def get_stats(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
from app.models import Show, Episode, Progress
total_shows = await db.scalar(
select(func.count(Show.id)).where(Show.user_id == user_id)
)
total_episodes = await db.scalar(
select(func.count(Episode.id)).where(Episode.user_id == user_id)
)
total_seconds = await db.scalar(
select(func.coalesce(func.sum(Progress.position_seconds), 0)).where(
Progress.user_id == user_id,
)
)
in_progress = await db.scalar(
select(func.count(Progress.id)).where(
Progress.user_id == user_id,
Progress.position_seconds > 0,
Progress.is_completed == False, # noqa: E712
)
)
return {
"total_shows": total_shows or 0,
"total_episodes": total_episodes or 0,
"total_listened_hours": round((total_seconds or 0) / 3600, 1),
"in_progress": in_progress or 0,
}

View File

@@ -0,0 +1,109 @@
"""SQLAlchemy models for the media service."""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Integer, BigInteger, Boolean, Float,
DateTime, ForeignKey, Index, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class Show(Base):
__tablename__ = "media_shows"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(String(64), nullable=False, index=True)
title = Column(String(500), nullable=False)
author = Column(String(500))
description = Column(Text)
artwork_url = Column(Text)
feed_url = Column(Text)
local_path = Column(Text)
show_type = Column(String(20), nullable=False, default="podcast")
etag = Column(String(255))
last_modified = Column(String(255))
last_fetched_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
episodes = relationship("Episode", back_populates="show", cascade="all, delete-orphan")
class Episode(Base):
__tablename__ = "media_episodes"
__table_args__ = (
UniqueConstraint("show_id", "guid", name="uq_media_episodes_show_guid"),
Index("idx_media_episodes_show", "show_id"),
Index("idx_media_episodes_published", "published_at"),
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
show_id = Column(UUID(as_uuid=True), ForeignKey("media_shows.id", ondelete="CASCADE"))
user_id = Column(String(64), nullable=False)
title = Column(String(1000))
description = Column(Text)
audio_url = Column(Text)
duration_seconds = Column(Integer)
file_size_bytes = Column(BigInteger)
published_at = Column(DateTime)
guid = Column(String(500))
artwork_url = Column(Text)
is_downloaded = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
show = relationship("Show", back_populates="episodes")
progress = relationship("Progress", back_populates="episode", uselist=False, cascade="all, delete-orphan")
class Progress(Base):
__tablename__ = "media_progress"
__table_args__ = (
UniqueConstraint("user_id", "episode_id", name="uq_media_progress_user_episode"),
Index("idx_media_progress_user", "user_id"),
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(String(64), nullable=False)
episode_id = Column(UUID(as_uuid=True), ForeignKey("media_episodes.id", ondelete="CASCADE"))
position_seconds = Column(Float, default=0)
duration_seconds = Column(Integer)
is_completed = Column(Boolean, default=False)
playback_speed = Column(Float, default=1.0)
last_played_at = Column(DateTime, default=datetime.utcnow)
episode = relationship("Episode", back_populates="progress")
class QueueItem(Base):
__tablename__ = "media_queue"
__table_args__ = (
UniqueConstraint("user_id", "episode_id", name="uq_media_queue_user_episode"),
Index("idx_media_queue_user_order", "user_id", "sort_order"),
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(String(64), nullable=False)
episode_id = Column(UUID(as_uuid=True), ForeignKey("media_episodes.id", ondelete="CASCADE"))
sort_order = Column(Integer, nullable=False, default=0)
added_at = Column(DateTime, default=datetime.utcnow)
episode = relationship("Episode")
class PlaybackEvent(Base):
__tablename__ = "media_playback_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(String(64), nullable=False)
episode_id = Column(UUID(as_uuid=True), ForeignKey("media_episodes.id", ondelete="CASCADE"))
event_type = Column(String(20), nullable=False)
position_seconds = Column(Float)
playback_speed = Column(Float, default=1.0)
created_at = Column(DateTime, default=datetime.utcnow)
episode = relationship("Episode")

View File

View File

@@ -0,0 +1,298 @@
"""Worker tasks — feed fetching, local folder scanning, scheduling loop."""
from __future__ import annotations
import logging
import os
import time
import uuid
from datetime import datetime
from time import mktime
import feedparser
import httpx
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import Session, sessionmaker
from app.config import DATABASE_URL_SYNC, FEED_FETCH_INTERVAL, AUDIO_EXTENSIONS
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
# Sync engine for worker
engine = create_engine(DATABASE_URL_SYNC, pool_size=5, max_overflow=2)
SessionLocal = sessionmaker(bind=engine)
def _parse_duration(value: str) -> int | None:
if not value:
return None
parts = value.strip().split(":")
try:
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
elif len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
else:
return int(float(parts[0]))
except (ValueError, IndexError):
return None
def fetch_feed(feed_url: str, etag: str = None, last_modified: str = None):
"""Fetch and parse an RSS feed synchronously."""
headers = {}
if etag:
headers["If-None-Match"] = etag
if last_modified:
headers["If-Modified-Since"] = last_modified
with httpx.Client(timeout=30, follow_redirects=True) as client:
resp = client.get(feed_url, headers=headers)
if resp.status_code == 304:
return None, None, None
resp.raise_for_status()
feed = feedparser.parse(resp.text)
new_etag = resp.headers.get("ETag")
new_lm = resp.headers.get("Last-Modified")
return feed, new_etag, new_lm
def refresh_rss_show(session: Session, show_id: str, feed_url: str, etag: str, last_modified: str, user_id: str):
"""Refresh a single RSS show, inserting new episodes."""
try:
feed, new_etag, new_lm = fetch_feed(feed_url, etag, last_modified)
except Exception as e:
log.error("Failed to fetch feed %s: %s", feed_url, e)
return 0
if feed is None:
log.debug("Feed %s not modified", feed_url)
return 0
# Update show metadata
session.execute(
text("""
UPDATE media_shows
SET etag = :etag, last_modified = :lm, last_fetched_at = NOW()
WHERE id = :id
"""),
{"etag": new_etag, "lm": new_lm, "id": show_id},
)
# Get existing guids
rows = session.execute(
text("SELECT guid FROM media_episodes WHERE show_id = :sid"),
{"sid": show_id},
).fetchall()
existing_guids = {r[0] for r in rows}
new_count = 0
for entry in feed.entries:
audio_url = None
file_size = None
for enc in getattr(entry, "enclosures", []):
if enc.get("type", "").startswith("audio/") or enc.get("href", "").split("?")[0].endswith(
(".mp3", ".m4a", ".ogg", ".opus")
):
audio_url = enc.get("href")
file_size = int(enc.get("length", 0)) or None
break
if not audio_url:
for link in getattr(entry, "links", []):
if link.get("type", "").startswith("audio/"):
audio_url = link.get("href")
file_size = int(link.get("length", 0)) or None
break
if not audio_url:
continue
guid = getattr(entry, "id", None) or audio_url
if guid in existing_guids:
continue
duration = None
itunes_duration = getattr(entry, "itunes_duration", None)
if itunes_duration:
duration = _parse_duration(str(itunes_duration))
published = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
published = datetime.fromtimestamp(mktime(entry.published_parsed))
except (TypeError, ValueError, OverflowError):
pass
ep_artwork = None
itunes_image = getattr(entry, "itunes_image", None)
if itunes_image and isinstance(itunes_image, dict):
ep_artwork = itunes_image.get("href")
session.execute(
text("""
INSERT INTO media_episodes
(id, show_id, user_id, title, description, audio_url,
duration_seconds, file_size_bytes, published_at, guid, artwork_url)
VALUES
(:id, :show_id, :user_id, :title, :desc, :audio_url,
:dur, :size, :pub, :guid, :art)
ON CONFLICT (show_id, guid) DO NOTHING
"""),
{
"id": str(uuid.uuid4()),
"show_id": show_id,
"user_id": user_id,
"title": getattr(entry, "title", None),
"desc": getattr(entry, "summary", None),
"audio_url": audio_url,
"dur": duration,
"size": file_size,
"pub": published,
"guid": guid,
"art": ep_artwork,
},
)
new_count += 1
session.commit()
if new_count:
log.info("Show %s: added %d new episodes", show_id, new_count)
return new_count
def refresh_local_show(session: Session, show_id: str, local_path: str, user_id: str):
"""Re-scan a local folder for new audio files."""
if not os.path.isdir(local_path):
log.warning("Local path does not exist: %s", local_path)
return 0
rows = session.execute(
text("SELECT guid FROM media_episodes WHERE show_id = :sid"),
{"sid": show_id},
).fetchall()
existing_guids = {r[0] for r in rows}
new_count = 0
for fname in sorted(os.listdir(local_path)):
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
fpath = os.path.join(local_path, fname)
if not os.path.isfile(fpath):
continue
guid = f"local:{fpath}"
if guid in existing_guids:
continue
title = os.path.splitext(fname)[0]
duration = None
file_size = os.path.getsize(fpath)
try:
from mutagen import File as MutagenFile
audio = MutagenFile(fpath)
if audio and audio.info:
duration = int(audio.info.length)
if audio and audio.tags:
for tag_key in ("title", "TIT2", "\xa9nam"):
tag_val = audio.tags.get(tag_key)
if tag_val:
title = str(tag_val[0]) if isinstance(tag_val, list) else str(tag_val)
break
except Exception:
pass
stat = os.stat(fpath)
published = datetime.fromtimestamp(stat.st_mtime)
session.execute(
text("""
INSERT INTO media_episodes
(id, show_id, user_id, title, audio_url,
duration_seconds, file_size_bytes, published_at, guid)
VALUES
(:id, :show_id, :user_id, :title, :audio_url,
:dur, :size, :pub, :guid)
ON CONFLICT (show_id, guid) DO NOTHING
"""),
{
"id": str(uuid.uuid4()),
"show_id": show_id,
"user_id": user_id,
"title": title,
"audio_url": fpath,
"dur": duration,
"size": file_size,
"pub": published,
"guid": guid,
},
)
new_count += 1
if new_count:
session.execute(
text("UPDATE media_shows SET last_fetched_at = NOW() WHERE id = :id"),
{"id": show_id},
)
session.commit()
log.info("Local show %s: added %d new files", show_id, new_count)
return new_count
def refresh_all_shows():
"""Refresh all shows — RSS and local."""
session = SessionLocal()
try:
rows = session.execute(
text("SELECT id, user_id, feed_url, local_path, show_type, etag, last_modified FROM media_shows")
).fetchall()
total_new = 0
for row in rows:
sid, uid, feed_url, local_path, show_type, etag, lm = row
if show_type == "podcast" and feed_url:
total_new += refresh_rss_show(session, str(sid), feed_url, etag, lm, uid)
elif show_type == "local" and local_path:
total_new += refresh_local_show(session, str(sid), local_path, uid)
if total_new:
log.info("Feed refresh complete: %d new episodes total", total_new)
except Exception:
log.exception("Error during feed refresh")
session.rollback()
finally:
session.close()
def run_scheduler():
"""Simple scheduler loop — refresh feeds at configured interval."""
log.info("Media worker started — refresh interval: %ds", FEED_FETCH_INTERVAL)
# Wait for DB to be ready
for attempt in range(10):
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
break
except Exception:
log.info("Waiting for database... (attempt %d)", attempt + 1)
time.sleep(3)
while True:
try:
refresh_all_shows()
except Exception:
log.exception("Scheduler loop error")
time.sleep(FEED_FETCH_INTERVAL)
if __name__ == "__main__":
run_scheduler()

View File

@@ -0,0 +1,44 @@
services:
media-api:
build:
context: .
dockerfile: Dockerfile.api
container_name: media-api
restart: unless-stopped
volumes:
- /media/yusiboyz/Media/Audiobooks:/audiobooks:ro
environment:
- DATABASE_URL=postgresql+asyncpg://brain:brain@brain-db:5432/brain
- REDIS_URL=redis://brain-redis:6379/0
- LOCAL_AUDIO_PATH=/audiobooks
- PORT=8400
- TZ=${TZ:-America/Chicago}
networks:
- default
- pangolin
- brain
media-worker:
build:
context: .
dockerfile: Dockerfile.worker
container_name: media-worker
restart: unless-stopped
volumes:
- /media/yusiboyz/Media/Audiobooks:/audiobooks:ro
environment:
- DATABASE_URL=postgresql+asyncpg://brain:brain@brain-db:5432/brain
- REDIS_URL=redis://brain-redis:6379/0
- LOCAL_AUDIO_PATH=/audiobooks
- FEED_FETCH_INTERVAL=1800
- TZ=${TZ:-America/Chicago}
networks:
- default
- brain
networks:
pangolin:
external: true
brain:
name: brain_default
external: true

View File

@@ -0,0 +1,12 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.30.0
psycopg2-binary==2.9.10
pydantic==2.10.0
httpx==0.28.0
feedparser==6.0.11
redis==5.2.0
rq==2.1.0
python-dateutil==2.9.0
mutagen==1.47.0