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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,188 +1,122 @@
import SwiftUI
import PhotosUI
struct HomeView: View {
@Environment(AuthManager.self) private var authManager
@State private var viewModel = HomeViewModel()
@Environment(AuthManager.self) private var auth
@State private var vm = HomeViewModel()
@State private var showProfileMenu = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
if viewModel.isLoading {
LoadingView(message: "Loading dashboard...")
.frame(height: 300)
ZStack {
// Background
if let bg = vm.backgroundImage {
Image(uiImage: bg)
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea()
} else {
// Quick Stats Card
caloriesSummaryCard
// Macros Card
macrosCard
// Quick Actions
quickActionsCard
Color.canvas.ignoresSafeArea()
}
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
}
}
.padding(16)
}
.background(Color.canvas)
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
ScrollView {
VStack(spacing: 16) {
// Top bar
HStack {
Text("Home")
.font(.largeTitle.weight(.bold))
.foregroundStyle(vm.hasBackground ? .white : Color.textPrimary)
Spacer()
Menu {
PhotosPicker(
selection: $vm.selectedPhoto,
matching: .images
) {
Label("Change Background", systemImage: "photo")
}
if vm.hasBackground {
Button(role: .destructive) {
authManager.logout()
vm.removeBackground()
} label: {
Label("Remove Background", systemImage: "trash")
}
}
Divider()
Button(role: .destructive) {
Task { await auth.logout() }
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Image(systemName: "person.circle.fill")
.font(.title3)
.foregroundStyle(Color.accentWarm)
}
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
}
private var caloriesSummaryCard: some View {
VStack(spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Today")
.font(.headline)
.foregroundStyle(Color.text1)
Text(Date().displayString)
.font(.subheadline)
.foregroundStyle(Color.text3)
}
Spacer()
Text("\(viewModel.entryCount) entries")
.font(.caption)
.foregroundStyle(Color.text4)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.surfaceSecondary)
.clipShape(Capsule())
}
MacroRingLarge(
current: viewModel.totalCalories,
goal: viewModel.goal.calories,
color: .caloriesColor,
size: 140,
lineWidth: 12
)
HStack(spacing: 0) {
macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal")
Spacer()
macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal")
Spacer()
macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal")
}
}
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
private var macrosCard: some View {
VStack(spacing: 14) {
Text("Macros")
.font(.headline)
.foregroundStyle(Color.text1)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 20) {
MacroRing(
current: viewModel.totalProtein,
goal: viewModel.goal.protein,
color: .proteinColor,
label: "Protein",
unit: "g",
size: 68
)
MacroRing(
current: viewModel.totalCarbs,
goal: viewModel.goal.carbs,
color: .carbsColor,
label: "Carbs",
unit: "g",
size: 68
)
MacroRing(
current: viewModel.totalFat,
goal: viewModel.goal.fat,
color: .fatColor,
label: "Fat",
unit: "g",
size: 68
)
}
.frame(maxWidth: .infinity)
}
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
private var quickActionsCard: some View {
VStack(spacing: 12) {
Text("Quick Actions")
.font(.headline)
.foregroundStyle(Color.text1)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 12) {
quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald)
quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor)
quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm)
}
}
.padding(20)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
private func macroStat(_ label: String, value: Int, unit: String) -> some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.system(.title3, design: .rounded, weight: .bold))
.foregroundStyle(Color.text1)
Text("\(label)")
.font(.caption2)
.foregroundStyle(Color.text4)
}
}
private func quickActionButton(icon: String, label: String, color: Color) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(color)
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(Color.text2)
.foregroundStyle(vm.hasBackground ? .white : Color.accentWarm)
}
}
.padding(.horizontal)
.padding(.top, 60)
// Widget grid
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
], spacing: 12) {
// Calorie widget
calorieWidget
.gridCellColumns(2)
}
.padding(.horizontal)
Spacer(minLength: 100)
}
}
}
.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)
.foregroundStyle(vm.hasBackground ? .white.opacity(0.6) : Color.textTertiary)
}
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 {
var todayEntries: [FoodEntry] = []
var goal: DailyGoal = DailyGoal()
var isLoading = true
var errorMessage: String?
private let repo = FitnessRepository.shared
var totalCalories: Double {
todayEntries.reduce(0) { $0 + $1.calories }
var totalCalories: Double = 0
var calorieGoal: Double = 2000
var isLoading = false
// Background image
var backgroundImage: UIImage?
var selectedPhoto: PhotosPickerItem?
private let backgroundKey = "homeBackgroundImage"
init() {
loadSavedBackground()
}
var totalProtein: Double {
todayEntries.reduce(0) { $0 + $1.protein }
var hasBackground: Bool {
backgroundImage != nil
}
var totalCarbs: Double {
todayEntries.reduce(0) { $0 + $1.carbs }
}
var totalFat: Double {
todayEntries.reduce(0) { $0 + $1.fat }
}
var entryCount: Int {
todayEntries.count
}
func load() async {
func loadTodayData() async {
isLoading = true
errorMessage = nil
let today = Date().apiDateString
do {
async let entriesTask = repo.entries(for: today, forceRefresh: true)
async let goalsTask = repo.goals(for: today)
todayEntries = try await entriesTask
goal = try await goalsTask
} catch {
errorMessage = error.localizedDescription
}
async let entriesTask: () = repo.loadEntries(date: today)
async let goalTask: () = repo.loadGoal(date: today)
_ = await (entriesTask, goalTask)
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
calorieGoal = repo.goal?.calories ?? 2000
isLoading = false
}
// MARK: - Background Image
func handlePhotoSelection() async {
guard let item = selectedPhoto else { return }
guard let data = try? await item.loadTransferable(type: Data.self) else { return }
guard let original = UIImage(data: data) else { return }
let resized = resizeImage(original, maxWidth: 1200)
backgroundImage = resized
if let jpegData = resized.jpegData(compressionQuality: 0.8) {
UserDefaults.standard.set(jpegData, forKey: backgroundKey)
}
selectedPhoto = nil
}
func removeBackground() {
backgroundImage = nil
UserDefaults.standard.removeObject(forKey: backgroundKey)
}
private func loadSavedBackground() {
if let data = UserDefaults.standard.data(forKey: backgroundKey),
let image = UIImage(data: data) {
backgroundImage = image
}
}
private func resizeImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage {
let scale = maxWidth / image.size.width
guard scale < 1 else { return image }
let newSize = CGSize(width: maxWidth, height: image.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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