feat: rebuild iOS app from API audit + new podcast/media service
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:
@@ -1,5 +0,0 @@
|
|||||||
# This directory is a Syncthing folder marker.
|
|
||||||
# Do not delete.
|
|
||||||
|
|
||||||
folderID: pvf5v-v6cle
|
|
||||||
created: 2026-04-03T03:07:29Z
|
|
||||||
@@ -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 */;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -7,42 +7,41 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
A10001 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10001 /* PlatformApp.swift */; };
|
A10001 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10001; };
|
||||||
A10002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10002 /* ContentView.swift */; };
|
A10002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10002; };
|
||||||
A10003 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10003 /* Config.swift */; };
|
A10003 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10003; };
|
||||||
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004 /* APIClient.swift */; };
|
A10004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10004; };
|
||||||
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
|
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005; };
|
||||||
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
|
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006; };
|
||||||
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
|
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007; };
|
||||||
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; };
|
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008; };
|
||||||
A10009 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10009 /* FitnessModels.swift */; };
|
A10009 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10009; };
|
||||||
A10010 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10010 /* FitnessAPI.swift */; };
|
A10010 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10010; };
|
||||||
A10011 /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10011 /* FitnessRepository.swift */; };
|
A10011 /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10011; };
|
||||||
A10012 /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10012 /* FitnessTabView.swift */; };
|
A10012 /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10012; };
|
||||||
A10013 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10013 /* TodayView.swift */; };
|
A10013 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10013; };
|
||||||
A10014 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10014 /* MealSectionView.swift */; };
|
A10014 /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10014; };
|
||||||
A10015 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10015 /* FoodSearchView.swift */; };
|
A10015 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10015; };
|
||||||
A10016 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10016 /* AddFoodSheet.swift */; };
|
A10016 /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10016; };
|
||||||
A10018 /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10018 /* TemplatesView.swift */; };
|
A10017 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10017; };
|
||||||
A10019 /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10019 /* GoalsView.swift */; };
|
A10018 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10018; };
|
||||||
A10020 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10020 /* EntryDetailView.swift */; };
|
A10019 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10019; };
|
||||||
A10021 /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10021 /* TodayViewModel.swift */; };
|
A10020 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10020; };
|
||||||
A10022 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10022 /* FoodSearchViewModel.swift */; };
|
A10021 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10021; };
|
||||||
A10024 /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10024 /* TemplatesViewModel.swift */; };
|
A10022 /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10022; };
|
||||||
A10025 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10025 /* GoalsViewModel.swift */; };
|
A10023 /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10023; };
|
||||||
A10026 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10026 /* MacroRing.swift */; };
|
A10024 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10024; };
|
||||||
A10027 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10027 /* MacroBar.swift */; };
|
A10025 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10025; };
|
||||||
A10028 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10028 /* LoadingView.swift */; };
|
A10026 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10026; };
|
||||||
A10029 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10029 /* Date+Extensions.swift */; };
|
A10027 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10027; };
|
||||||
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030 /* Color+Extensions.swift */; };
|
A10028 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10028; };
|
||||||
A10031 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10031 /* Assets.xcassets */; };
|
A10029 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10029; };
|
||||||
A10032 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10032 /* FoodLibraryView.swift */; };
|
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030; };
|
||||||
A10033 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10033 /* AssistantChatView.swift */; };
|
A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; };
|
||||||
A10034 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034 /* AssistantViewModel.swift */; };
|
A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
B10000 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
B10001 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformApp.swift; sourceTree = "<group>"; };
|
B10001 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformApp.swift; sourceTree = "<group>"; };
|
||||||
B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
B10003 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
|
B10003 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
|
||||||
@@ -54,31 +53,33 @@
|
|||||||
B10009 /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessModels.swift; sourceTree = "<group>"; };
|
B10009 /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessModels.swift; sourceTree = "<group>"; };
|
||||||
B10010 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessAPI.swift; sourceTree = "<group>"; };
|
B10010 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessAPI.swift; sourceTree = "<group>"; };
|
||||||
B10011 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessRepository.swift; sourceTree = "<group>"; };
|
B10011 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessRepository.swift; sourceTree = "<group>"; };
|
||||||
B10012 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessTabView.swift; sourceTree = "<group>"; };
|
B10012 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = "<group>"; };
|
||||||
B10013 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
B10013 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchViewModel.swift; sourceTree = "<group>"; };
|
||||||
B10014 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSectionView.swift; sourceTree = "<group>"; };
|
B10014 /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesViewModel.swift; sourceTree = "<group>"; };
|
||||||
B10015 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchView.swift; sourceTree = "<group>"; };
|
B10015 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsViewModel.swift; sourceTree = "<group>"; };
|
||||||
B10016 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFoodSheet.swift; sourceTree = "<group>"; };
|
B10016 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessTabView.swift; sourceTree = "<group>"; };
|
||||||
B10018 /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesView.swift; sourceTree = "<group>"; };
|
B10017 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
||||||
B10019 /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsView.swift; sourceTree = "<group>"; };
|
B10018 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSectionView.swift; sourceTree = "<group>"; };
|
||||||
B10020 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDetailView.swift; sourceTree = "<group>"; };
|
B10019 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchView.swift; sourceTree = "<group>"; };
|
||||||
B10021 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = "<group>"; };
|
B10020 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFoodSheet.swift; sourceTree = "<group>"; };
|
||||||
B10022 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchViewModel.swift; sourceTree = "<group>"; };
|
B10021 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodLibraryView.swift; sourceTree = "<group>"; };
|
||||||
B10024 /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesViewModel.swift; sourceTree = "<group>"; };
|
B10022 /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesView.swift; sourceTree = "<group>"; };
|
||||||
B10025 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsViewModel.swift; sourceTree = "<group>"; };
|
B10023 /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsView.swift; sourceTree = "<group>"; };
|
||||||
B10026 /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroRing.swift; sourceTree = "<group>"; };
|
B10024 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDetailView.swift; sourceTree = "<group>"; };
|
||||||
B10027 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroBar.swift; sourceTree = "<group>"; };
|
B10025 /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantChatView.swift; sourceTree = "<group>"; };
|
||||||
B10028 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
B10026 /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantViewModel.swift; sourceTree = "<group>"; };
|
||||||
B10029 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
B10027 /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroRing.swift; sourceTree = "<group>"; };
|
||||||
|
B10028 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroBar.swift; sourceTree = "<group>"; };
|
||||||
|
B10029 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
||||||
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
B10031 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
B10032 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodLibraryView.swift; sourceTree = "<group>"; };
|
B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
B10033 /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantChatView.swift; sourceTree = "<group>"; };
|
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
B10034 /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantViewModel.swift; sourceTree = "<group>"; };
|
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
C10001 /* Frameworks */ = {
|
E10001 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -88,29 +89,30 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
G10000 = {
|
F10001 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
G10001 /* Platform */,
|
F10002 /* Platform */,
|
||||||
G10099 /* Products */,
|
F10020 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10001 /* Platform */ = {
|
F10002 /* Platform */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10001 /* PlatformApp.swift */,
|
B10001 /* PlatformApp.swift */,
|
||||||
B10002 /* ContentView.swift */,
|
B10002 /* ContentView.swift */,
|
||||||
B10003 /* Config.swift */,
|
B10003 /* Config.swift */,
|
||||||
B10031 /* Assets.xcassets */,
|
B10033 /* Info.plist */,
|
||||||
G10002 /* Core */,
|
C10001 /* Assets.xcassets */,
|
||||||
G10003 /* Features */,
|
F10003 /* Core */,
|
||||||
G10004 /* Shared */,
|
F10004 /* Features */,
|
||||||
|
F10015 /* Shared */,
|
||||||
);
|
);
|
||||||
path = Platform;
|
path = Platform;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10002 /* Core */ = {
|
F10003 /* Core */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10004 /* APIClient.swift */,
|
B10004 /* APIClient.swift */,
|
||||||
@@ -119,46 +121,18 @@
|
|||||||
path = Core;
|
path = Core;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10003 /* Features */ = {
|
F10004 /* Features */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
G10010 /* Auth */,
|
F10005 /* Auth */,
|
||||||
G10011 /* Home */,
|
F10006 /* Home */,
|
||||||
G10012 /* Fitness */,
|
F10007 /* Fitness */,
|
||||||
G10018 /* Assistant */,
|
F10014 /* Assistant */,
|
||||||
);
|
);
|
||||||
path = Features;
|
path = Features;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10004 /* Shared */ = {
|
F10005 /* Auth */ = {
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
G10005 /* Components */,
|
|
||||||
G10006 /* Extensions */,
|
|
||||||
);
|
|
||||||
path = Shared;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
G10005 /* Components */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
B10026 /* MacroRing.swift */,
|
|
||||||
B10027 /* MacroBar.swift */,
|
|
||||||
B10028 /* LoadingView.swift */,
|
|
||||||
);
|
|
||||||
path = Components;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
G10006 /* Extensions */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
B10029 /* Date+Extensions.swift */,
|
|
||||||
B10030 /* Color+Extensions.swift */,
|
|
||||||
);
|
|
||||||
path = Extensions;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
G10010 /* Auth */ = {
|
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10006 /* LoginView.swift */,
|
B10006 /* LoginView.swift */,
|
||||||
@@ -166,7 +140,7 @@
|
|||||||
path = Auth;
|
path = Auth;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10011 /* Home */ = {
|
F10006 /* Home */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10007 /* HomeView.swift */,
|
B10007 /* HomeView.swift */,
|
||||||
@@ -175,19 +149,19 @@
|
|||||||
path = Home;
|
path = Home;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10012 /* Fitness */ = {
|
F10007 /* Fitness */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
G10013 /* Models */,
|
F10008 /* Models */,
|
||||||
G10014 /* API */,
|
F10009 /* API */,
|
||||||
G10015 /* Repository */,
|
F10010 /* Repository */,
|
||||||
G10016 /* Views */,
|
F10011 /* ViewModels */,
|
||||||
G10017 /* ViewModels */,
|
F10012 /* Views */,
|
||||||
);
|
);
|
||||||
path = Fitness;
|
path = Fitness;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10013 /* Models */ = {
|
F10008 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10009 /* FitnessModels.swift */,
|
B10009 /* FitnessModels.swift */,
|
||||||
@@ -195,7 +169,7 @@
|
|||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10014 /* API */ = {
|
F10009 /* API */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10010 /* FitnessAPI.swift */,
|
B10010 /* FitnessAPI.swift */,
|
||||||
@@ -203,7 +177,7 @@
|
|||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10015 /* Repository */ = {
|
F10010 /* Repository */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10011 /* FitnessRepository.swift */,
|
B10011 /* FitnessRepository.swift */,
|
||||||
@@ -211,46 +185,74 @@
|
|||||||
path = Repository;
|
path = Repository;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10016 /* Views */ = {
|
F10011 /* ViewModels */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10012 /* FitnessTabView.swift */,
|
B10012 /* TodayViewModel.swift */,
|
||||||
B10013 /* TodayView.swift */,
|
B10013 /* FoodSearchViewModel.swift */,
|
||||||
B10014 /* MealSectionView.swift */,
|
B10014 /* TemplatesViewModel.swift */,
|
||||||
B10015 /* FoodSearchView.swift */,
|
B10015 /* GoalsViewModel.swift */,
|
||||||
B10016 /* AddFoodSheet.swift */,
|
|
||||||
B10018 /* TemplatesView.swift */,
|
|
||||||
B10019 /* GoalsView.swift */,
|
|
||||||
B10020 /* EntryDetailView.swift */,
|
|
||||||
B10032 /* FoodLibraryView.swift */,
|
|
||||||
);
|
|
||||||
path = Views;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
G10017 /* ViewModels */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
B10021 /* TodayViewModel.swift */,
|
|
||||||
B10022 /* FoodSearchViewModel.swift */,
|
|
||||||
B10024 /* TemplatesViewModel.swift */,
|
|
||||||
B10025 /* GoalsViewModel.swift */,
|
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10018 /* Assistant */ = {
|
F10012 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10033 /* AssistantChatView.swift */,
|
B10016 /* FitnessTabView.swift */,
|
||||||
B10034 /* AssistantViewModel.swift */,
|
B10017 /* TodayView.swift */,
|
||||||
|
B10018 /* MealSectionView.swift */,
|
||||||
|
B10019 /* FoodSearchView.swift */,
|
||||||
|
B10020 /* AddFoodSheet.swift */,
|
||||||
|
B10021 /* FoodLibraryView.swift */,
|
||||||
|
B10022 /* TemplatesView.swift */,
|
||||||
|
B10023 /* GoalsView.swift */,
|
||||||
|
B10024 /* EntryDetailView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10014 /* Assistant */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10025 /* AssistantChatView.swift */,
|
||||||
|
B10026 /* AssistantViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = Assistant;
|
path = Assistant;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
G10099 /* Products */ = {
|
F10015 /* Shared */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B10000 /* Platform.app */,
|
F10016 /* Components */,
|
||||||
|
F10017 /* Extensions */,
|
||||||
|
);
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10016 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10027 /* MacroRing.swift */,
|
||||||
|
B10028 /* MacroBar.swift */,
|
||||||
|
B10029 /* LoadingView.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10017 /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10030 /* Color+Extensions.swift */,
|
||||||
|
B10031 /* Date+Extensions.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F10020 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D10001 /* Platform.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -258,13 +260,13 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
T10001 /* Platform */ = {
|
G10001 /* Platform */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = CL10002 /* Build configuration list for PBXNativeTarget "Platform" */;
|
buildConfigurationList = H10003 /* Build configuration list for PBXNativeTarget "Platform" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
S10001 /* Sources */,
|
G10002 /* Sources */,
|
||||||
C10001 /* Frameworks */,
|
E10001 /* Frameworks */,
|
||||||
R10001 /* Resources */,
|
G10003 /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -272,25 +274,25 @@
|
|||||||
);
|
);
|
||||||
name = Platform;
|
name = Platform;
|
||||||
productName = Platform;
|
productName = Platform;
|
||||||
productReference = B10000 /* Platform.app */;
|
productReference = D10001 /* Platform.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
P10001 /* Project object */ = {
|
G10010 /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 1540;
|
LastSwiftUpdateCheck = 1540;
|
||||||
LastUpgradeCheck = 1540;
|
LastUpgradeCheck = 1540;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
T10001 = {
|
G10001 = {
|
||||||
CreatedOnToolsVersion = 15.4;
|
CreatedOnToolsVersion = 15.4;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = CL10001 /* Build configuration list for PBXProject "Platform" */;
|
buildConfigurationList = H10001 /* Build configuration list for PBXProject "Platform" */;
|
||||||
compatibilityVersion = "Xcode 14.0";
|
compatibilityVersion = "Xcode 14.0";
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
@@ -298,29 +300,29 @@
|
|||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = G10000;
|
mainGroup = F10001;
|
||||||
productRefGroup = G10099 /* Products */;
|
productRefGroup = F10020 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
T10001 /* Platform */,
|
G10001 /* Platform */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
R10001 /* Resources */ = {
|
G10003 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A10031 /* Assets.xcassets in Resources */,
|
A10032 /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
S10001 /* Sources */ = {
|
G10002 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -335,33 +337,33 @@
|
|||||||
A10009 /* FitnessModels.swift in Sources */,
|
A10009 /* FitnessModels.swift in Sources */,
|
||||||
A10010 /* FitnessAPI.swift in Sources */,
|
A10010 /* FitnessAPI.swift in Sources */,
|
||||||
A10011 /* FitnessRepository.swift in Sources */,
|
A10011 /* FitnessRepository.swift in Sources */,
|
||||||
A10012 /* FitnessTabView.swift in Sources */,
|
A10012 /* TodayViewModel.swift in Sources */,
|
||||||
A10013 /* TodayView.swift in Sources */,
|
A10013 /* FoodSearchViewModel.swift in Sources */,
|
||||||
A10014 /* MealSectionView.swift in Sources */,
|
A10014 /* TemplatesViewModel.swift in Sources */,
|
||||||
A10015 /* FoodSearchView.swift in Sources */,
|
A10015 /* GoalsViewModel.swift in Sources */,
|
||||||
A10016 /* AddFoodSheet.swift in Sources */,
|
A10016 /* FitnessTabView.swift in Sources */,
|
||||||
A10018 /* TemplatesView.swift in Sources */,
|
A10017 /* TodayView.swift in Sources */,
|
||||||
A10019 /* GoalsView.swift in Sources */,
|
A10018 /* MealSectionView.swift in Sources */,
|
||||||
A10020 /* EntryDetailView.swift in Sources */,
|
A10019 /* FoodSearchView.swift in Sources */,
|
||||||
A10021 /* TodayViewModel.swift in Sources */,
|
A10020 /* AddFoodSheet.swift in Sources */,
|
||||||
A10022 /* FoodSearchViewModel.swift in Sources */,
|
A10021 /* FoodLibraryView.swift in Sources */,
|
||||||
A10024 /* TemplatesViewModel.swift in Sources */,
|
A10022 /* TemplatesView.swift in Sources */,
|
||||||
A10025 /* GoalsViewModel.swift in Sources */,
|
A10023 /* GoalsView.swift in Sources */,
|
||||||
A10026 /* MacroRing.swift in Sources */,
|
A10024 /* EntryDetailView.swift in Sources */,
|
||||||
A10027 /* MacroBar.swift in Sources */,
|
A10025 /* AssistantChatView.swift in Sources */,
|
||||||
A10028 /* LoadingView.swift in Sources */,
|
A10026 /* AssistantViewModel.swift in Sources */,
|
||||||
A10029 /* Date+Extensions.swift in Sources */,
|
A10027 /* MacroRing.swift in Sources */,
|
||||||
|
A10028 /* MacroBar.swift in Sources */,
|
||||||
|
A10029 /* LoadingView.swift in Sources */,
|
||||||
A10030 /* Color+Extensions.swift in Sources */,
|
A10030 /* Color+Extensions.swift in Sources */,
|
||||||
A10032 /* FoodLibraryView.swift in Sources */,
|
A10031 /* Date+Extensions.swift in Sources */,
|
||||||
A10033 /* AssistantChatView.swift in Sources */,
|
|
||||||
A10034 /* AssistantViewModel.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
BC10001 /* Debug */ = {
|
H10010 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
@@ -414,6 +416,7 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -423,7 +426,7 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
BC10002 /* Release */ = {
|
H10011 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
@@ -470,6 +473,7 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -478,17 +482,17 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
BC10003 /* Debug */ = {
|
H10012 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Platform/Info.plist;
|
INFOPLIST_FILE = Platform/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Platform;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -501,25 +505,23 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
|
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
|
||||||
SUPPORTS_MACCATALYST = NO;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
BC10004 /* Release */ = {
|
H10013 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = CRN5A2VZ79;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Platform/Info.plist;
|
INFOPLIST_FILE = Platform/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Platform;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -532,8 +534,6 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
|
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
|
||||||
SUPPORTS_MACCATALYST = NO;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -543,25 +543,26 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
CL10001 /* Build configuration list for PBXProject "Platform" */ = {
|
H10001 /* Build configuration list for PBXProject "Platform" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
BC10001 /* Debug */,
|
H10010 /* Debug */,
|
||||||
BC10002 /* Release */,
|
H10011 /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
CL10002 /* Build configuration list for PBXNativeTarget "Platform" */ = {
|
H10003 /* Build configuration list for PBXNativeTarget "Platform" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
BC10003 /* Debug */,
|
H10012 /* Debug */,
|
||||||
BC10004 /* Release */,
|
H10013 /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
};
|
};
|
||||||
rootObject = P10001 /* Project object */;
|
rootObject = G10010 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Config {
|
enum Config {
|
||||||
static let gatewayURL: URL = {
|
static let gatewayURL = "https://dash.quadjourney.com"
|
||||||
if let override = UserDefaults.standard.string(forKey: "gateway_url"),
|
static let appName = "Platform"
|
||||||
let url = URL(string: override) {
|
static let appVersion = "1.0.0"
|
||||||
return url
|
|
||||||
}
|
|
||||||
return URL(string: "https://dash.quadjourney.com")!
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func apiURL(_ path: String) -> URL {
|
|
||||||
gatewayURL.appendingPathComponent(path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,90 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(AuthManager.self) private var authManager
|
@Environment(AuthManager.self) private var auth
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if authManager.isCheckingAuth {
|
if auth.isCheckingAuth {
|
||||||
LoadingView(message: "Checking session...")
|
ProgressView()
|
||||||
} else if authManager.isLoggedIn {
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.canvas)
|
||||||
|
} else if auth.isLoggedIn {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
} else {
|
} else {
|
||||||
LoginView()
|
LoginView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await authManager.checkAuth()
|
await auth.checkAuth()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
@State private var showAssistant = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
HomeView()
|
HomeView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Home", systemImage: "house.fill")
|
Label("Home", systemImage: "house.fill")
|
||||||
}
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
FitnessTabView()
|
FitnessTabView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Fitness", systemImage: "flame.fill")
|
Label("Fitness", systemImage: "flame.fill")
|
||||||
}
|
}
|
||||||
|
.tag(1)
|
||||||
}
|
}
|
||||||
.tint(Color.accentWarm)
|
.tint(Color.accentWarm)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showAssistant = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
.padding(.bottom, 70)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAssistant) {
|
||||||
|
AssistantSheetView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AssistantSheetView: View {
|
||||||
|
@State private var selectedMode = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Picker("Mode", selection: $selectedMode) {
|
||||||
|
Text("AI Chat").tag(0)
|
||||||
|
Text("Quick Add").tag(1)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
if selectedMode == 0 {
|
||||||
|
AssistantChatView()
|
||||||
|
} else {
|
||||||
|
FoodSearchView(isSheet: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Food")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum APIError: LocalizedError {
|
enum APIError: Error, LocalizedError {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case httpError(Int, String?)
|
case httpError(Int, String)
|
||||||
case decodingError(Error)
|
case decodingError(Error)
|
||||||
case networkError(Error)
|
case networkError(Error)
|
||||||
case unknown(String)
|
case unauthorized
|
||||||
|
case unknown
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .invalidURL: return "Invalid URL"
|
case .invalidURL: return "Invalid URL"
|
||||||
case .httpError(let code, let msg): return msg ?? "HTTP error \(code)"
|
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
|
||||||
case .decodingError(let err): return "Decoding error: \(err.localizedDescription)"
|
case .decodingError(let err): return "Decoding error: \(err.localizedDescription)"
|
||||||
case .networkError(let err): return err.localizedDescription
|
case .networkError(let err): return err.localizedDescription
|
||||||
case .unknown(let msg): return msg
|
case .unauthorized: return "Unauthorized"
|
||||||
|
case .unknown: return "Unknown error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,35 +25,63 @@ final class APIClient {
|
|||||||
static let shared = APIClient()
|
static let shared = APIClient()
|
||||||
|
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
private let baseURL: String
|
||||||
|
private let encoder: JSONEncoder
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
baseURL = Config.gatewayURL
|
||||||
|
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.httpCookieAcceptPolicy = .always
|
config.httpCookieAcceptPolicy = .always
|
||||||
config.httpShouldSetCookies = true
|
config.httpShouldSetCookies = true
|
||||||
config.httpCookieStorage = .shared
|
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||||
session = URLSession(configuration: config)
|
session = URLSession(configuration: config)
|
||||||
|
|
||||||
|
encoder = JSONEncoder()
|
||||||
|
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||||
|
|
||||||
decoder = JSONDecoder()
|
decoder = JSONDecoder()
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildURL(_ path: String) throws -> URL {
|
// MARK: - Generic Request
|
||||||
guard let url = URL(string: "\(Config.gatewayURL)\(path)") else {
|
|
||||||
|
func request<T: Decodable>(
|
||||||
|
_ method: String,
|
||||||
|
path: String,
|
||||||
|
body: (any Encodable)? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
let data = try await rawRequest(method, path: path, body: body, queryItems: queryItems)
|
||||||
|
do {
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw APIError.decodingError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawRequest(
|
||||||
|
_ method: String,
|
||||||
|
path: String,
|
||||||
|
body: (any Encodable)? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil
|
||||||
|
) async throws -> Data {
|
||||||
|
guard var components = URLComponents(string: baseURL + path) else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
return url
|
if let queryItems, !queryItems.isEmpty {
|
||||||
|
components.queryItems = queryItems
|
||||||
|
}
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func request<T: Decodable>(_ method: String, _ path: String, body: Encodable? = nil) async throws -> T {
|
|
||||||
let url = try buildURL(path)
|
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.httpMethod = method
|
req.httpMethod = method
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
||||||
|
|
||||||
if let body = body {
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
if let body {
|
||||||
req.httpBody = try encoder.encode(body)
|
req.httpBody = try encoder.encode(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,83 +92,93 @@ final class APIClient {
|
|||||||
throw APIError.networkError(error)
|
throw APIError.networkError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
!(200...299).contains(httpResponse.statusCode) {
|
throw APIError.unknown
|
||||||
let bodyStr = String(data: data, encoding: .utf8)
|
|
||||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
if httpResponse.statusCode == 401 {
|
||||||
return try decoder.decode(T.self, from: data)
|
throw APIError.unauthorized
|
||||||
} catch {
|
|
||||||
throw APIError.decodingError(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func get<T: Decodable>(_ path: String) async throws -> T {
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
try await request("GET", path)
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
throw APIError.httpError(httpResponse.statusCode, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func post<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
return data
|
||||||
try await request("POST", path, body: body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func patch<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
// MARK: - Convenience Methods
|
||||||
try await request("PATCH", path, body: body)
|
|
||||||
|
func get<T: Decodable>(
|
||||||
|
_ path: String,
|
||||||
|
queryItems: [URLQueryItem]? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
try await request("GET", path: path, queryItems: queryItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
func put<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
func post<T: Decodable>(
|
||||||
try await request("PUT", path, body: body)
|
_ path: String,
|
||||||
|
body: any Encodable
|
||||||
|
) async throws -> T {
|
||||||
|
try await request("POST", path: path, body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ path: String) async throws {
|
func patch<T: Decodable>(
|
||||||
let url = try buildURL(path)
|
_ path: String,
|
||||||
var req = URLRequest(url: url)
|
body: any Encodable
|
||||||
req.httpMethod = "DELETE"
|
) async throws -> T {
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
try await request("PATCH", path: path, body: body)
|
||||||
|
|
||||||
let (data, response): (Data, URLResponse)
|
|
||||||
do {
|
|
||||||
(data, response) = try await session.data(for: req)
|
|
||||||
} catch {
|
|
||||||
throw APIError.networkError(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let httpResponse = response as? HTTPURLResponse,
|
func put<T: Decodable>(
|
||||||
!(200...299).contains(httpResponse.statusCode) {
|
_ path: String,
|
||||||
let bodyStr = String(data: data, encoding: .utf8)
|
body: any Encodable
|
||||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
) async throws -> T {
|
||||||
}
|
try await request("PUT", path: path, body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawPost(_ path: String, body: Data) async throws -> (Data, URLResponse) {
|
func delete<T: Decodable>(_ path: String) async throws -> T {
|
||||||
let url = try buildURL(path)
|
try await request("DELETE", path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Raw POST (for assistant — sends/receives raw Data)
|
||||||
|
|
||||||
|
func rawPost(path: String, data: Data) async throws -> Data {
|
||||||
|
guard let url = URL(string: baseURL + path) else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
req.httpBody = data
|
||||||
req.httpBody = body
|
|
||||||
|
|
||||||
let (data, response): (Data, URLResponse)
|
let (responseData, response): (Data, URLResponse)
|
||||||
do {
|
do {
|
||||||
(data, response) = try await session.data(for: req)
|
(responseData, response) = try await session.data(for: req)
|
||||||
} catch {
|
} catch {
|
||||||
throw APIError.networkError(error)
|
throw APIError.networkError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
!(200...299).contains(httpResponse.statusCode) {
|
throw APIError.unknown
|
||||||
let bodyStr = String(data: data, encoding: .utf8)
|
|
||||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (data, response)
|
if httpResponse.statusCode == 401 {
|
||||||
|
throw APIError.unauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return responseData
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cookie Management
|
||||||
|
|
||||||
func clearCookies() {
|
func clearCookies() {
|
||||||
if let cookies = HTTPCookieStorage.shared.cookies {
|
guard let url = URL(string: baseURL) else { return }
|
||||||
|
let storage = HTTPCookieStorage.shared
|
||||||
|
if let cookies = storage.cookies(for: url) {
|
||||||
for cookie in cookies {
|
for cookie in cookies {
|
||||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
storage.deleteCookie(cookie)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AuthUser: Codable {
|
@Observable
|
||||||
let id: Int
|
final class AuthManager {
|
||||||
let username: String
|
var isLoggedIn = false
|
||||||
let displayName: String?
|
var isCheckingAuth = true
|
||||||
|
var currentUser: GatewayUser?
|
||||||
|
var error: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
private let api = APIClient.shared
|
||||||
case id, username
|
private let loggedInKey = "isLoggedIn"
|
||||||
case displayName = "display_name"
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init() {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
|
||||||
// Handle id as Int or String
|
|
||||||
if let intId = try? container.decode(Int.self, forKey: .id) {
|
|
||||||
id = intId
|
|
||||||
} else if let strId = try? container.decode(String.self, forKey: .id),
|
|
||||||
let parsed = Int(strId) {
|
|
||||||
id = parsed
|
|
||||||
} else {
|
|
||||||
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [CodingKeys.id], debugDescription: "Expected Int or String for id"))
|
|
||||||
}
|
|
||||||
username = try container.decode(String.self, forKey: .username)
|
|
||||||
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoginRequest: Encodable {
|
struct LoginRequest: Encodable {
|
||||||
@@ -33,26 +21,18 @@ struct LoginRequest: Encodable {
|
|||||||
|
|
||||||
struct LoginResponse: Decodable {
|
struct LoginResponse: Decodable {
|
||||||
let success: Bool
|
let success: Bool
|
||||||
let user: AuthUser?
|
let user: GatewayUser
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MeResponse: Decodable {
|
struct AuthCheckResponse: Decodable {
|
||||||
let authenticated: Bool
|
let authenticated: Bool
|
||||||
let user: AuthUser?
|
let user: GatewayUser?
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
struct GatewayUser: Decodable, Sendable {
|
||||||
final class AuthManager {
|
let id: Int
|
||||||
var isLoggedIn = false
|
let username: String
|
||||||
var isCheckingAuth = true
|
let displayName: String
|
||||||
var user: AuthUser?
|
|
||||||
var loginError: String?
|
|
||||||
|
|
||||||
private let api = APIClient.shared
|
|
||||||
private let loggedInKey = "isLoggedIn"
|
|
||||||
|
|
||||||
init() {
|
|
||||||
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkAuth() async {
|
func checkAuth() async {
|
||||||
@@ -62,9 +42,9 @@ final class AuthManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let response: MeResponse = try await api.get("/api/auth/me")
|
let response: AuthCheckResponse = try await api.get("/api/auth/me")
|
||||||
if response.authenticated {
|
if response.authenticated, let user = response.user {
|
||||||
user = response.user
|
currentUser = user
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
} else {
|
} else {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
@@ -78,27 +58,30 @@ final class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func login(username: String, password: String) async {
|
func login(username: String, password: String) async {
|
||||||
loginError = nil
|
error = nil
|
||||||
do {
|
do {
|
||||||
let response: LoginResponse = try await api.post("/api/auth/login", body: LoginRequest(username: username, password: password))
|
let response: LoginResponse = try await api.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
body: LoginRequest(username: username, password: password)
|
||||||
|
)
|
||||||
if response.success {
|
if response.success {
|
||||||
user = response.user
|
currentUser = response.user
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
UserDefaults.standard.set(true, forKey: loggedInKey)
|
UserDefaults.standard.set(true, forKey: loggedInKey)
|
||||||
} else {
|
|
||||||
loginError = "Invalid credentials"
|
|
||||||
}
|
}
|
||||||
} catch let error as APIError {
|
} catch let apiError as APIError {
|
||||||
loginError = error.localizedDescription
|
error = apiError.localizedDescription
|
||||||
} catch {
|
} catch {
|
||||||
loginError = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func logout() {
|
func logout() async {
|
||||||
|
struct LogoutResponse: Decodable { let success: Bool }
|
||||||
|
_ = try? await api.post("/api/auth/logout", body: [String: String]()) as LogoutResponse
|
||||||
api.clearCookies()
|
api.clearCookies()
|
||||||
|
currentUser = nil
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
user = nil
|
|
||||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,285 +1,224 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
|
||||||
enum AssistantTab: String, CaseIterable {
|
|
||||||
case chat = "AI Chat"
|
|
||||||
case quickAdd = "Quick Add"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AssistantChatView: View {
|
struct AssistantChatView: View {
|
||||||
let entryDate: String
|
@State private var vm = AssistantViewModel()
|
||||||
let onDismiss: () -> Void
|
|
||||||
|
|
||||||
@Environment(AuthManager.self) private var authManager
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@State private var vm: AssistantViewModel
|
|
||||||
@State private var selectedTab: AssistantTab = .chat
|
|
||||||
@State private var scrollProxy: ScrollViewProxy?
|
|
||||||
|
|
||||||
init(entryDate: String, onDismiss: @escaping () -> Void) {
|
|
||||||
self.entryDate = entryDate
|
|
||||||
self.onDismiss = onDismiss
|
|
||||||
_vm = State(initialValue: AssistantViewModel(entryDate: entryDate, username: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Tab switcher
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(AssistantTab.allCases, id: \.self) { tab in
|
|
||||||
Button {
|
|
||||||
selectedTab = tab
|
|
||||||
} label: {
|
|
||||||
Text(tab.rawValue)
|
|
||||||
.font(.subheadline.bold())
|
|
||||||
.foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(selectedTab == tab ? Color.accentWarm.opacity(0.1) : Color.clear)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(4)
|
|
||||||
.background(Color.surfaceSecondary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
if selectedTab == .chat {
|
|
||||||
chatContent
|
|
||||||
} else {
|
|
||||||
FoodSearchView(mealType: .snack, dateString: entryDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.canvas)
|
|
||||||
.navigationTitle("Assistant")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Done") {
|
|
||||||
dismiss()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
vm = AssistantViewModel(entryDate: entryDate, username: authManager.user?.username)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var chatContent: some View {
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Messages
|
// Messages
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 8) {
|
||||||
ForEach(vm.messages) { message in
|
ForEach(vm.messages) { message in
|
||||||
messageBubble(message)
|
chatBubble(message)
|
||||||
.id(message.id)
|
.id(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draft card
|
||||||
|
if let draft = vm.currentDraft, !vm.applied {
|
||||||
|
draftCard(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple drafts
|
||||||
|
if vm.currentDrafts.count > 1 && !vm.applied {
|
||||||
|
multipleDraftsCard
|
||||||
|
}
|
||||||
|
|
||||||
if vm.isLoading {
|
if vm.isLoading {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(Color.accentWarm)
|
.controlSize(.small)
|
||||||
Text("Thinking...")
|
Text("Thinking...")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.textTertiary)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal)
|
||||||
.id("loading")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
.onAppear { scrollProxy = proxy }
|
|
||||||
.onChange(of: vm.messages.count) { _, _ in
|
|
||||||
withAnimation {
|
|
||||||
proxy.scrollTo(vm.messages.last?.id, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = vm.error {
|
if let error = vm.error {
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal)
|
||||||
.padding(.bottom, 4)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.onChange(of: vm.messages.count) {
|
||||||
|
if let last = vm.messages.last {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(last.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
// Input bar
|
// Input bar
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 10) {
|
||||||
PhotosPicker(selection: Binding(
|
PhotosPicker(selection: $vm.selectedPhoto, matching: .images) {
|
||||||
get: { vm.selectedPhoto },
|
Image(systemName: "camera.fill")
|
||||||
set: { newVal in
|
|
||||||
vm.selectedPhoto = newVal
|
|
||||||
Task { await vm.loadPhoto(newVal) }
|
|
||||||
}
|
|
||||||
), matching: .images) {
|
|
||||||
Image(systemName: vm.photoData != nil ? "photo.fill" : "photo")
|
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(vm.photoData != nil ? Color.accentWarm : Color.textSecondary)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextField("Ask anything...", text: $vm.inputText)
|
TextField("Describe your food...", text: $vm.inputText)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.padding(10)
|
.onSubmit {
|
||||||
.background(Color.surfaceSecondary)
|
Task { await vm.send() }
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task { await vm.send() }
|
||||||
await vm.send()
|
|
||||||
withAnimation {
|
|
||||||
scrollProxy?.scrollTo(vm.messages.last?.id, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accentWarm)
|
.foregroundStyle(
|
||||||
|
vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
? Color.textTertiary
|
||||||
|
: Color.accentWarm
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.disabled(vm.inputText.isEmpty && vm.photoData == nil)
|
.disabled(vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty || vm.isLoading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
}
|
||||||
|
.onChange(of: vm.selectedPhoto) {
|
||||||
|
Task { await vm.handlePhotoSelection() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Bubble
|
||||||
|
|
||||||
|
private func chatBubble(_ message: ChatMessage) -> some View {
|
||||||
|
HStack {
|
||||||
|
if message.role == "user" { Spacer(minLength: 60) }
|
||||||
|
|
||||||
|
Text(message.content)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(message.role == "user" ? .white : Color.textPrimary)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
message.role == "user"
|
||||||
|
? Color.accentWarm
|
||||||
|
: Color.surfaceSheet
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
|
||||||
|
if message.role == "assistant" { Spacer(minLength: 60) }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.surface)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Draft Card
|
||||||
private func messageBubble(_ message: ChatMessage) -> some View {
|
|
||||||
if message.role == "user" {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Text(message.content)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
.padding(12)
|
|
||||||
.background(Color(hex: "8B6914").opacity(0.15))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.frame(maxWidth: 280, alignment: .trailing)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if !message.content.isEmpty {
|
|
||||||
Text(message.content)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
.padding(12)
|
|
||||||
.background(Color.assistantBubble)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.frame(maxWidth: 300, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draft cards
|
private func draftCard(_ draft: FitnessDraft) -> some View {
|
||||||
ForEach(Array(message.drafts.enumerated()), id: \.offset) { _, draft in
|
|
||||||
draftCard(draft, applied: message.applied)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source links
|
|
||||||
if !message.sources.isEmpty {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(message.sources) { source in
|
|
||||||
if let url = URL(string: source.href), !source.href.isEmpty {
|
|
||||||
Link(destination: url) {
|
|
||||||
sourceChip(source)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sourceChip(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func draftCard(_ draft: FitnessDraft, applied: Bool) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(draft.foodName)
|
Image(systemName: "doc.text.fill")
|
||||||
.font(.subheadline.bold())
|
|
||||||
.foregroundStyle(Color.textPrimary)
|
|
||||||
Spacer()
|
|
||||||
Text(draft.mealType.capitalized)
|
|
||||||
.font(.caption2.bold())
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
.padding(.horizontal, 8)
|
Text("Draft")
|
||||||
.padding(.vertical, 3)
|
.font(.subheadline.weight(.semibold))
|
||||||
.background(Color.accentWarm.opacity(0.1))
|
.foregroundStyle(Color.textPrimary)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
Text(draft.foodName)
|
||||||
macroChip("Cal", Int(draft.calories))
|
.font(.headline)
|
||||||
macroChip("P", Int(draft.protein))
|
.foregroundStyle(Color.textPrimary)
|
||||||
macroChip("C", Int(draft.carbs))
|
|
||||||
macroChip("F", Int(draft.fat))
|
HStack(spacing: 16) {
|
||||||
|
miniStat("Cal", value: Int(draft.calories))
|
||||||
|
miniStat("P", value: Int(draft.protein))
|
||||||
|
miniStat("C", value: Int(draft.carbs))
|
||||||
|
miniStat("F", value: Int(draft.fat))
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("\(draft.mealType.capitalized) \u{2022} \(draft.quantity, specifier: "%.1f") \(draft.unit)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !applied {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Button {
|
Button {
|
||||||
Task { await vm.applyDraft() }
|
Task { await vm.applyDraft() }
|
||||||
} label: {
|
} label: {
|
||||||
Text("Add it")
|
Text("Add it")
|
||||||
.font(.caption.bold())
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 16)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 10)
|
||||||
.background(Color.emerald)
|
.background(Color.emerald)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
.padding()
|
||||||
HStack {
|
.background(Color.surfaceCard)
|
||||||
Image(systemName: "checkmark.circle.fill")
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.foregroundStyle(Color.emerald)
|
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
|
||||||
Text("Added")
|
.padding(.horizontal, 12)
|
||||||
.font(.caption.bold())
|
|
||||||
.foregroundStyle(Color.emerald)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
|
||||||
.frame(maxWidth: 300, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func macroChip(_ label: String, _ value: Int) -> some View {
|
private var multipleDraftsCard: some View {
|
||||||
VStack(spacing: 2) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("\(value)")
|
HStack {
|
||||||
.font(.caption.bold())
|
Image(systemName: "doc.on.doc.fill")
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
Text("\(vm.currentDrafts.count) Items")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
Text(label)
|
}
|
||||||
.font(.system(size: 9))
|
|
||||||
|
ForEach(vm.currentDrafts) { draft in
|
||||||
|
HStack {
|
||||||
|
Text(draft.foodName)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(draft.calories)) kcal")
|
||||||
|
.font(.caption)
|
||||||
.foregroundStyle(Color.textSecondary)
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sourceChip(_ source: SourceLink) -> some View {
|
let totalCals = vm.currentDrafts.reduce(0.0) { $0 + $1.calories }
|
||||||
HStack(spacing: 4) {
|
Text("Total: \(Int(totalCals)) kcal")
|
||||||
Image(systemName: source.type == "brain" ? "brain" : "link")
|
.font(.caption.weight(.semibold))
|
||||||
.font(.system(size: 10))
|
.foregroundStyle(Color.textSecondary)
|
||||||
Text(source.title)
|
|
||||||
.font(.caption2)
|
Button {
|
||||||
.lineLimit(1)
|
Task { await vm.applyAllDrafts() }
|
||||||
}
|
} label: {
|
||||||
.foregroundStyle(Color.accentWarm)
|
Text("Add all")
|
||||||
.padding(.horizontal, 10)
|
.font(.subheadline.weight(.semibold))
|
||||||
.padding(.vertical, 6)
|
.foregroundStyle(.white)
|
||||||
.background(Color.accentWarm.opacity(0.08))
|
.frame(maxWidth: .infinity)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.emerald)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func miniStat(_ label: String, value: Int) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(value)")
|
||||||
|
.font(.caption.weight(.bold).monospacedDigit())
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 9).weight(.medium))
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import SwiftUI
|
import Foundation
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct ChatMessage: Identifiable {
|
struct ChatMessage: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let role: String // "user" or "assistant"
|
let role: String // "user" or "assistant"
|
||||||
let content: String
|
let content: String
|
||||||
var drafts: [FitnessDraft] = []
|
let timestamp = Date()
|
||||||
var sources: [SourceLink] = []
|
|
||||||
var applied: Bool = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -16,115 +15,129 @@ final class AssistantViewModel {
|
|||||||
var inputText = ""
|
var inputText = ""
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var error: String?
|
var error: String?
|
||||||
var selectedPhoto: PhotosPickerItem?
|
var entryDate = Date()
|
||||||
var photoData: Data?
|
|
||||||
|
|
||||||
// Raw JSON state from server — never decode this
|
// State from server — stored as raw JSON, never decoded with Codable
|
||||||
private var serverState: Any?
|
private var serverState: Any?
|
||||||
private let api = APIClient.shared
|
|
||||||
private var entryDate: String
|
|
||||||
private var allowBrain: Bool
|
|
||||||
|
|
||||||
init(entryDate: String, username: String?) {
|
// Draft data — parsed manually from raw dict
|
||||||
self.entryDate = entryDate
|
var currentDraft: FitnessDraft?
|
||||||
self.allowBrain = (username ?? "") != "madiha"
|
var currentDrafts: [FitnessDraft] = []
|
||||||
}
|
var applied = false
|
||||||
|
|
||||||
func send(action: String = "chat") async {
|
|
||||||
let text = inputText.trimmingCharacters(in: .whitespaces)
|
|
||||||
guard !text.isEmpty || action == "apply" else { return }
|
|
||||||
|
|
||||||
if action == "chat" && !text.isEmpty {
|
|
||||||
messages.append(ChatMessage(role: "user", content: text))
|
|
||||||
inputText = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Build request as raw JSON
|
|
||||||
var requestDict: [String: Any] = [
|
|
||||||
"entryDate": entryDate,
|
|
||||||
"action": action,
|
|
||||||
"allowBrain": allowBrain
|
|
||||||
]
|
|
||||||
|
|
||||||
// Messages array
|
|
||||||
let msgArray = messages.filter { $0.role == "user" }.map { msg -> [String: String] in
|
|
||||||
["role": "user", "content": msg.content]
|
|
||||||
}
|
|
||||||
requestDict["messages"] = msgArray
|
|
||||||
|
|
||||||
// State pass-through
|
|
||||||
if let state = serverState {
|
|
||||||
requestDict["state"] = state
|
|
||||||
} else {
|
|
||||||
requestDict["state"] = NSNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Photo
|
// Photo
|
||||||
if let data = photoData {
|
var selectedPhoto: PhotosPickerItem?
|
||||||
let base64 = data.base64EncodedString()
|
var imageDataUrl: String?
|
||||||
requestDict["imageDataUrl"] = "data:image/jpeg;base64,\(base64)"
|
|
||||||
photoData = nil
|
private let api = FitnessAPI()
|
||||||
} else {
|
|
||||||
requestDict["imageDataUrl"] = NSNull()
|
var dateString: String {
|
||||||
|
entryDate.apiDateString
|
||||||
}
|
}
|
||||||
|
|
||||||
let bodyData = try JSONSerialization.data(withJSONObject: requestDict)
|
// MARK: - Send Message
|
||||||
let (responseData, _) = try await api.rawPost("/api/assistant/chat", body: bodyData)
|
|
||||||
|
|
||||||
// Parse response as raw JSON
|
func send() async {
|
||||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
error = "Invalid response"
|
guard !text.isEmpty else { return }
|
||||||
|
|
||||||
|
messages.append(ChatMessage(role: "user", content: text))
|
||||||
|
inputText = ""
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
applied = false
|
||||||
|
|
||||||
|
await doSend(action: "chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDraft() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
await doSend(action: "apply")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAllDrafts() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
await doSend(action: "apply")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doSend(action: String) async {
|
||||||
|
do {
|
||||||
|
// Build message array for API
|
||||||
|
let msgArray: [[String: String]] = messages.map { msg in
|
||||||
|
["role": msg.role, "content": msg.content]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request as raw dictionary
|
||||||
|
var requestDict: [String: Any] = [
|
||||||
|
"messages": msgArray,
|
||||||
|
"action": action,
|
||||||
|
"entryDate": dateString,
|
||||||
|
"allowBrain": false,
|
||||||
|
]
|
||||||
|
|
||||||
|
if let state = serverState {
|
||||||
|
requestDict["state"] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
if let draft = currentDraft {
|
||||||
|
requestDict["draft"] = draftToDict(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !currentDrafts.isEmpty {
|
||||||
|
requestDict["drafts"] = currentDrafts.map { draftToDict($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let imageUrl = imageDataUrl {
|
||||||
|
requestDict["imageDataUrl"] = imageUrl
|
||||||
|
imageDataUrl = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestData = try JSONSerialization.data(withJSONObject: requestDict)
|
||||||
|
let responseData = try await api.sendAssistantMessage(data: requestData)
|
||||||
|
|
||||||
|
// Parse response as raw dict
|
||||||
|
guard let responseDict = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||||
|
error = "Invalid response from server"
|
||||||
isLoading = false
|
isLoading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store raw state
|
|
||||||
serverState = json["state"]
|
|
||||||
|
|
||||||
// Extract display fields
|
// Extract display fields
|
||||||
let reply = json["reply"] as? String ?? ""
|
if let reply = responseDict["reply"] as? String, !reply.isEmpty {
|
||||||
let applied = json["applied"] as? Bool ?? false
|
messages.append(ChatMessage(role: "assistant", content: reply))
|
||||||
|
}
|
||||||
|
|
||||||
// Parse drafts
|
// Store state as raw
|
||||||
var drafts: [FitnessDraft] = []
|
if let state = responseDict["state"] {
|
||||||
if let draft = json["draft"] as? [String: Any], let d = FitnessDraft(from: draft) {
|
serverState = state
|
||||||
drafts.append(d)
|
|
||||||
}
|
}
|
||||||
if let draftsArray = json["drafts"] as? [[String: Any]] {
|
|
||||||
for dict in draftsArray {
|
// Parse draft
|
||||||
if let d = FitnessDraft(from: dict) {
|
if let draftDict = responseDict["draft"] as? [String: Any] {
|
||||||
drafts.append(d)
|
currentDraft = FitnessDraft(from: draftDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse drafts array
|
||||||
|
if let draftsArray = responseDict["drafts"] as? [[String: Any]] {
|
||||||
|
currentDrafts = draftsArray.map { FitnessDraft(from: $0) }
|
||||||
|
if currentDraft == nil && !currentDrafts.isEmpty {
|
||||||
|
currentDraft = currentDrafts.first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse sources
|
// Check applied
|
||||||
var sources: [SourceLink] = []
|
if let appliedValue = responseDict["applied"] as? Bool {
|
||||||
if let sourcesArray = json["sources"] as? [[String: Any]] {
|
applied = appliedValue
|
||||||
for dict in sourcesArray {
|
if appliedValue {
|
||||||
if let s = SourceLink(from: dict) {
|
currentDraft = nil
|
||||||
sources.append(s)
|
currentDrafts = []
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for error
|
if let errorMsg = responseDict["error"] as? String {
|
||||||
if let errStr = json["error"] as? String, !errStr.isEmpty {
|
error = errorMsg
|
||||||
error = errStr
|
|
||||||
}
|
|
||||||
|
|
||||||
if !reply.isEmpty || !drafts.isEmpty {
|
|
||||||
messages.append(ChatMessage(
|
|
||||||
role: "assistant",
|
|
||||||
content: reply,
|
|
||||||
drafts: drafts,
|
|
||||||
sources: sources,
|
|
||||||
applied: applied
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
@@ -134,17 +147,60 @@ final class AssistantViewModel {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDraft() async {
|
// MARK: - Photo handling
|
||||||
await send(action: "apply")
|
|
||||||
|
func handlePhotoSelection() async {
|
||||||
|
guard let item = selectedPhoto else { return }
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self) else { return }
|
||||||
|
|
||||||
|
// Resize for upload
|
||||||
|
guard let image = UIImage(data: data) else { return }
|
||||||
|
let maxDim: CGFloat = 800
|
||||||
|
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
|
||||||
|
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||||
|
let resized = renderer.image { _ in
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPhoto(_ item: PhotosPickerItem?) async {
|
if let jpegData = resized.jpegData(compressionQuality: 0.7) {
|
||||||
guard let item else { return }
|
let base64 = jpegData.base64EncodedString()
|
||||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
imageDataUrl = "data:image/jpeg;base64,\(base64)"
|
||||||
// Compress as JPEG
|
messages.append(ChatMessage(role: "user", content: "[Photo attached]"))
|
||||||
if let img = UIImage(data: data), let jpeg = img.jpegData(compressionQuality: 0.7) {
|
await doSend(action: "chat")
|
||||||
photoData = jpeg
|
}
|
||||||
}
|
|
||||||
}
|
selectedPhoto = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func draftToDict(_ draft: FitnessDraft) -> [String: Any] {
|
||||||
|
[
|
||||||
|
"food_name": draft.foodName,
|
||||||
|
"meal_type": draft.mealType,
|
||||||
|
"entry_date": draft.entryDate,
|
||||||
|
"quantity": draft.quantity,
|
||||||
|
"unit": draft.unit,
|
||||||
|
"calories": draft.calories,
|
||||||
|
"protein": draft.protein,
|
||||||
|
"carbs": draft.carbs,
|
||||||
|
"fat": draft.fat,
|
||||||
|
"sugar": draft.sugar,
|
||||||
|
"fiber": draft.fiber,
|
||||||
|
"note": draft.note,
|
||||||
|
"default_serving_label": draft.defaultServingLabel,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
messages = []
|
||||||
|
inputText = ""
|
||||||
|
serverState = nil
|
||||||
|
currentDraft = nil
|
||||||
|
currentDrafts = []
|
||||||
|
applied = false
|
||||||
|
error = nil
|
||||||
|
imageDataUrl = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +1,73 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@Environment(AuthManager.self) private var authManager
|
@Environment(AuthManager.self) private var auth
|
||||||
|
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@FocusState private var focusedField: Field?
|
|
||||||
|
|
||||||
private enum Field: Hashable {
|
|
||||||
case username, password
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
|
||||||
Color.canvas
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 32) {
|
VStack(spacing: 32) {
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 60)
|
|
||||||
|
|
||||||
// Logo / Branding
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "square.grid.2x2.fill")
|
Image(systemName: "square.grid.2x2.fill")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
|
||||||
Text("Platform")
|
Text("Platform")
|
||||||
.font(.largeTitle.weight(.bold))
|
.font(.largeTitle.weight(.bold))
|
||||||
.foregroundStyle(Color.text1)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
Text("Sign in to your account")
|
||||||
Text("Sign in to your dashboard")
|
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
TextField("Username", text: $username)
|
||||||
Text("Username")
|
.textFieldStyle(.roundedBorder)
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
TextField("Enter username", text: $username)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.focused($focusedField, equals: .username)
|
.textInputAutocapitalization(.never)
|
||||||
.padding(14)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(
|
|
||||||
focusedField == .username ? Color.accentWarm : Color.black.opacity(0.06),
|
|
||||||
lineWidth: focusedField == .username ? 2 : 1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
SecureField("Password", text: $password)
|
||||||
Text("Password")
|
.textFieldStyle(.roundedBorder)
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
SecureField("Enter password", text: $password)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
.focused($focusedField, equals: .password)
|
|
||||||
.padding(14)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(
|
|
||||||
focusedField == .password ? Color.accentWarm : Color.black.opacity(0.06),
|
|
||||||
lineWidth: focusedField == .password ? 2 : 1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = authManager.loginError {
|
if let error = auth.error {
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
|
||||||
.foregroundStyle(Color.error)
|
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.subheadline)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.error)
|
.foregroundStyle(.red)
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
|
|
||||||
// Sign In Button
|
|
||||||
Button {
|
Button {
|
||||||
performLogin()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
.tint(.white)
|
|
||||||
}
|
|
||||||
Text("Sign In")
|
|
||||||
.font(.body.weight(.semibold))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(canSubmit ? Color.accentWarm : Color.text4)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
.disabled(!canSubmit)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onSubmit {
|
|
||||||
switch focusedField {
|
|
||||||
case .username:
|
|
||||||
focusedField = .password
|
|
||||||
case .password:
|
|
||||||
performLogin()
|
|
||||||
case .none:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var canSubmit: Bool {
|
|
||||||
!username.trimmingCharacters(in: .whitespaces).isEmpty
|
|
||||||
&& !password.isEmpty
|
|
||||||
&& !isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performLogin() {
|
|
||||||
guard canSubmit else { return }
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
focusedField = nil
|
|
||||||
Task {
|
Task {
|
||||||
await authManager.login(
|
await auth.login(username: username, password: password)
|
||||||
username: username.trimmingCharacters(in: .whitespaces),
|
|
||||||
password: password
|
|
||||||
)
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
} else {
|
||||||
|
Text("Sign In")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
||||||
|
.opacity(username.isEmpty || password.isEmpty ? 0.6 : 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,55 +2,84 @@ import Foundation
|
|||||||
|
|
||||||
struct FitnessAPI {
|
struct FitnessAPI {
|
||||||
private let api = APIClient.shared
|
private let api = APIClient.shared
|
||||||
|
private let basePath = "/api/fitness"
|
||||||
|
|
||||||
|
// MARK: - Entries
|
||||||
|
|
||||||
func getEntries(date: String) async throws -> [FoodEntry] {
|
func getEntries(date: String) async throws -> [FoodEntry] {
|
||||||
try await api.get("/api/fitness/entries?date=\(date)")
|
try await api.get(
|
||||||
|
"\(basePath)/entries",
|
||||||
|
queryItems: [URLQueryItem(name: "date", value: date)]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
|
func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry {
|
||||||
try await api.post("/api/fitness/entries", body: req)
|
try await api.post("\(basePath)/entries", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(id: String, quantity: Double) async throws -> FoodEntry {
|
func deleteEntry(id: String) async throws -> SuccessResponse {
|
||||||
struct Body: Encodable { let quantity: Double }
|
try await api.delete("\(basePath)/entries/\(id)")
|
||||||
return try await api.patch("/api/fitness/entries/\(id)", body: Body(quantity: quantity))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteEntry(id: String) async throws {
|
// MARK: - Goals
|
||||||
try await api.delete("/api/fitness/entries/\(id)")
|
|
||||||
|
func getGoalsForDate(date: String) async throws -> DailyGoal {
|
||||||
|
try await api.get(
|
||||||
|
"\(basePath)/goals/for-date",
|
||||||
|
queryItems: [URLQueryItem(name: "date", value: date)]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFoods(limit: Int = 100) async throws -> [FoodItem] {
|
// MARK: - Foods
|
||||||
try await api.get("/api/fitness/foods?limit=\(limit)")
|
|
||||||
|
func getFoods(limit: Int = 100) async throws -> [Food] {
|
||||||
|
try await api.get(
|
||||||
|
"\(basePath)/foods",
|
||||||
|
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchFoods(query: String, limit: Int = 20) async throws -> [FoodItem] {
|
func searchFoods(query: String, limit: Int = 20) async throws -> [Food] {
|
||||||
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
|
try await api.get(
|
||||||
return try await api.get("/api/fitness/foods/search?q=\(encoded)&limit=\(limit)")
|
"\(basePath)/foods/search",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "q", value: query),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRecentFoods(limit: Int = 8) async throws -> [FoodItem] {
|
func getRecentFoods(limit: Int = 20) async throws -> [RecentFood] {
|
||||||
try await api.get("/api/fitness/foods/recent?limit=\(limit)")
|
try await api.get(
|
||||||
|
"\(basePath)/foods/recent",
|
||||||
|
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFood(id: String) async throws -> FoodItem {
|
func getFood(id: String) async throws -> Food {
|
||||||
try await api.get("/api/fitness/foods/\(id)")
|
try await api.get("\(basePath)/foods/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGoals(date: String) async throws -> DailyGoal {
|
// MARK: - Templates
|
||||||
try await api.get("/api/fitness/goals/for-date?date=\(date)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateGoals(_ req: UpdateGoalsRequest) async throws -> DailyGoal {
|
|
||||||
try await api.put("/api/fitness/goals", body: req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTemplates() async throws -> [MealTemplate] {
|
func getTemplates() async throws -> [MealTemplate] {
|
||||||
try await api.get("/api/fitness/templates")
|
try await api.get("\(basePath)/templates")
|
||||||
}
|
}
|
||||||
|
|
||||||
func logTemplate(id: String, date: String) async throws {
|
func logTemplate(id: String, mealType: String, entryDate: String) async throws -> TemplateLogResponse {
|
||||||
struct Empty: Decodable {}
|
struct LogBody: Encodable {
|
||||||
let _: Empty = try await api.post("/api/fitness/templates/\(id)/log?date=\(date)")
|
let mealType: String
|
||||||
|
let entryDate: String
|
||||||
|
}
|
||||||
|
return try await api.post(
|
||||||
|
"\(basePath)/templates/\(id)/log",
|
||||||
|
body: LogBody(mealType: mealType, entryDate: entryDate)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Assistant (raw)
|
||||||
|
|
||||||
|
func sendAssistantMessage(data: Data) async throws -> Data {
|
||||||
|
try await api.rawPost(path: "/api/assistant/fitness", data: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,270 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Meal Type
|
// MARK: - Flexible Number Decoding
|
||||||
|
|
||||||
enum MealType: String, Codable, CaseIterable, Identifiable {
|
/// Decodes a JSON value that may be Int, Double, or String into a Double
|
||||||
|
struct FlexibleDouble: Decodable {
|
||||||
|
let value: Double
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let d = try? container.decode(Double.self) {
|
||||||
|
value = d
|
||||||
|
} else if let i = try? container.decode(Int.self) {
|
||||||
|
value = Double(i)
|
||||||
|
} else if let s = try? container.decode(String.self), let d = Double(s) {
|
||||||
|
value = d
|
||||||
|
} else if container.decodeNil() {
|
||||||
|
value = 0
|
||||||
|
} else {
|
||||||
|
value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ v: Double) { value = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Food Entry (from GET /api/entries?date=...)
|
||||||
|
|
||||||
|
struct FoodEntry: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String?
|
||||||
|
let foodId: String?
|
||||||
|
let mealType: String
|
||||||
|
let entryDate: String
|
||||||
|
let entryType: String
|
||||||
|
let quantity: Double
|
||||||
|
let unit: String
|
||||||
|
let servingDescription: String?
|
||||||
|
let snapshotFoodName: String
|
||||||
|
let snapshotServingLabel: String?
|
||||||
|
let snapshotGrams: Double?
|
||||||
|
let snapshotCalories: Double
|
||||||
|
let snapshotProtein: Double
|
||||||
|
let snapshotCarbs: Double
|
||||||
|
let snapshotFat: Double
|
||||||
|
let snapshotSugar: Double
|
||||||
|
let snapshotFiber: Double
|
||||||
|
let source: String
|
||||||
|
let entryMethod: String
|
||||||
|
let rawText: String?
|
||||||
|
let confidenceScore: Double?
|
||||||
|
let note: String?
|
||||||
|
let imageRef: String?
|
||||||
|
let aiMetadata: String?
|
||||||
|
let idempotencyKey: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let foodImagePath: String?
|
||||||
|
|
||||||
|
// Computed convenience properties
|
||||||
|
var foodName: String { snapshotFoodName }
|
||||||
|
var calories: Double { snapshotCalories }
|
||||||
|
var protein: Double { snapshotProtein }
|
||||||
|
var carbs: Double { snapshotCarbs }
|
||||||
|
var fat: Double { snapshotFat }
|
||||||
|
var sugar: Double { snapshotSugar }
|
||||||
|
var fiber: Double { snapshotFiber }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Goal (from GET /api/goals/for-date?date=...)
|
||||||
|
|
||||||
|
struct DailyGoal: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let startDate: String
|
||||||
|
let endDate: String?
|
||||||
|
let calories: Double
|
||||||
|
let protein: Double
|
||||||
|
let carbs: Double
|
||||||
|
let fat: Double
|
||||||
|
let sugar: Double
|
||||||
|
let fiber: Double
|
||||||
|
let isActive: Int
|
||||||
|
let createdAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Food Serving (nested in Food)
|
||||||
|
|
||||||
|
struct FoodServing: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let foodId: String
|
||||||
|
let name: String
|
||||||
|
let amountInBase: Double
|
||||||
|
let isDefault: Int
|
||||||
|
let createdAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Food (from GET /api/foods, /api/foods/search)
|
||||||
|
|
||||||
|
struct Food: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let normalizedName: String?
|
||||||
|
let brand: String?
|
||||||
|
let brandNormalized: String?
|
||||||
|
let barcode: String?
|
||||||
|
let notes: String?
|
||||||
|
let caloriesPerBase: Double
|
||||||
|
let proteinPerBase: Double
|
||||||
|
let carbsPerBase: Double
|
||||||
|
let fatPerBase: Double
|
||||||
|
let sugarPerBase: Double
|
||||||
|
let fiberPerBase: Double
|
||||||
|
let baseUnit: String
|
||||||
|
let status: String
|
||||||
|
let createdByUserId: String?
|
||||||
|
let isShared: Int?
|
||||||
|
let imagePath: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let servings: [FoodServing]?
|
||||||
|
|
||||||
|
// Search-specific fields (only present in search results)
|
||||||
|
let score: Double?
|
||||||
|
let matchType: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent Food (from GET /api/foods/recent)
|
||||||
|
|
||||||
|
struct RecentFood: Decodable, Identifiable {
|
||||||
|
let foodId: String
|
||||||
|
let snapshotFoodName: String
|
||||||
|
let caloriesPerBase: Double
|
||||||
|
let proteinPerBase: Double
|
||||||
|
let carbsPerBase: Double
|
||||||
|
let fatPerBase: Double
|
||||||
|
let sugarPerBase: Double
|
||||||
|
let fiberPerBase: Double
|
||||||
|
let baseUnit: String
|
||||||
|
let lastUsed: String
|
||||||
|
|
||||||
|
var id: String { foodId }
|
||||||
|
var name: String { snapshotFoodName }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Meal Template (from GET /api/templates)
|
||||||
|
|
||||||
|
struct MealTemplate: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let name: String
|
||||||
|
let mealType: String?
|
||||||
|
let isFavorite: Int
|
||||||
|
let isArchived: Int
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
let items: [MealTemplateItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MealTemplateItem: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let templateId: String
|
||||||
|
let foodId: String
|
||||||
|
let quantity: Double
|
||||||
|
let unit: String
|
||||||
|
let servingDescription: String?
|
||||||
|
let snapshotFoodName: String
|
||||||
|
let snapshotCalories: Double
|
||||||
|
let snapshotProtein: Double
|
||||||
|
let snapshotCarbs: Double
|
||||||
|
let snapshotFat: Double
|
||||||
|
let snapshotSugar: Double
|
||||||
|
let snapshotFiber: Double
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
var foodName: String { snapshotFoodName }
|
||||||
|
var calories: Double { snapshotCalories }
|
||||||
|
var protein: Double { snapshotProtein }
|
||||||
|
var carbs: Double { snapshotCarbs }
|
||||||
|
var fat: Double { snapshotFat }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create Entry Request
|
||||||
|
|
||||||
|
struct CreateEntryRequest: Encodable {
|
||||||
|
let foodId: String
|
||||||
|
let quantity: Double
|
||||||
|
let unit: String
|
||||||
|
let mealType: String
|
||||||
|
let entryDate: String
|
||||||
|
let entryMethod: String
|
||||||
|
let source: String
|
||||||
|
let servingId: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
foodId: String,
|
||||||
|
quantity: Double = 1.0,
|
||||||
|
unit: String = "serving",
|
||||||
|
mealType: String = "snack",
|
||||||
|
entryDate: String,
|
||||||
|
entryMethod: String = "manual",
|
||||||
|
source: String = "ios",
|
||||||
|
servingId: String? = nil
|
||||||
|
) {
|
||||||
|
self.foodId = foodId
|
||||||
|
self.quantity = quantity
|
||||||
|
self.unit = unit
|
||||||
|
self.mealType = mealType
|
||||||
|
self.entryDate = entryDate
|
||||||
|
self.entryMethod = entryMethod
|
||||||
|
self.source = source
|
||||||
|
self.servingId = servingId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Response
|
||||||
|
|
||||||
|
struct SuccessResponse: Decodable {
|
||||||
|
let success: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Template Log Response
|
||||||
|
|
||||||
|
struct TemplateLogResponse: Decodable {
|
||||||
|
let logged: Int
|
||||||
|
let entries: [FoodEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fitness Draft (from assistant — manual init from dictionary)
|
||||||
|
|
||||||
|
struct FitnessDraft: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let foodName: String
|
||||||
|
let mealType: String
|
||||||
|
let entryDate: String
|
||||||
|
let quantity: Double
|
||||||
|
let unit: String
|
||||||
|
let calories: Double
|
||||||
|
let protein: Double
|
||||||
|
let carbs: Double
|
||||||
|
let fat: Double
|
||||||
|
let sugar: Double
|
||||||
|
let fiber: Double
|
||||||
|
let note: String
|
||||||
|
let defaultServingLabel: String
|
||||||
|
|
||||||
|
init(from dict: [String: Any]) {
|
||||||
|
foodName = dict["food_name"] as? String ?? ""
|
||||||
|
mealType = dict["meal_type"] as? String ?? "snack"
|
||||||
|
entryDate = dict["entry_date"] as? String ?? ""
|
||||||
|
quantity = (dict["quantity"] as? Double) ?? (dict["quantity"] as? Int).map(Double.init) ?? 1.0
|
||||||
|
unit = dict["unit"] as? String ?? "serving"
|
||||||
|
calories = (dict["calories"] as? Double) ?? (dict["calories"] as? Int).map(Double.init) ?? 0
|
||||||
|
protein = (dict["protein"] as? Double) ?? (dict["protein"] as? Int).map(Double.init) ?? 0
|
||||||
|
carbs = (dict["carbs"] as? Double) ?? (dict["carbs"] as? Int).map(Double.init) ?? 0
|
||||||
|
fat = (dict["fat"] as? Double) ?? (dict["fat"] as? Int).map(Double.init) ?? 0
|
||||||
|
sugar = (dict["sugar"] as? Double) ?? (dict["sugar"] as? Int).map(Double.init) ?? 0
|
||||||
|
fiber = (dict["fiber"] as? Double) ?? (dict["fiber"] as? Int).map(Double.init) ?? 0
|
||||||
|
note = dict["note"] as? String ?? ""
|
||||||
|
defaultServingLabel = dict["default_serving_label"] as? String ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Meal Type Helpers
|
||||||
|
|
||||||
|
enum MealType: String, CaseIterable {
|
||||||
case breakfast, lunch, dinner, snack
|
case breakfast, lunch, dinner, snack
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
rawValue.capitalized
|
rawValue.capitalized
|
||||||
}
|
}
|
||||||
@@ -16,509 +273,17 @@ enum MealType: String, Codable, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .breakfast: return "sunrise.fill"
|
case .breakfast: return "sunrise.fill"
|
||||||
case .lunch: return "sun.max.fill"
|
case .lunch: return "sun.max.fill"
|
||||||
case .dinner: return "moon.fill"
|
case .dinner: return "moon.stars.fill"
|
||||||
case .snack: return "leaf.fill"
|
case .snack: return "leaf.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var capitalized: String {
|
var sortOrder: Int {
|
||||||
rawValue.capitalized
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Guess meal type based on current time of day
|
|
||||||
static func guess() -> MealType {
|
|
||||||
let hour = Calendar.current.component(.hour, from: Date())
|
|
||||||
switch hour {
|
|
||||||
case 5..<11: return .breakfast
|
|
||||||
case 11..<15: return .lunch
|
|
||||||
case 15..<17: return .snack
|
|
||||||
case 17..<22: return .dinner
|
|
||||||
default: return .snack
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var color: Color {
|
|
||||||
switch self {
|
switch self {
|
||||||
case .breakfast: return .breakfastColor
|
case .breakfast: return 0
|
||||||
case .lunch: return .lunchColor
|
case .lunch: return 1
|
||||||
case .dinner: return .dinnerColor
|
case .dinner: return 2
|
||||||
case .snack: return .snackColor
|
case .snack: return 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Food Entry
|
|
||||||
// API fields (snake_case) are auto-converted to camelCase by decoder.
|
|
||||||
// The API returns snapshot_food_name, snapshot_calories, etc. — no top-level food_name/calories.
|
|
||||||
|
|
||||||
struct FoodEntry: Identifiable, Codable {
|
|
||||||
let id: String
|
|
||||||
let userId: String?
|
|
||||||
let foodId: String?
|
|
||||||
let mealType: MealType
|
|
||||||
let quantity: Double
|
|
||||||
let entryDate: String
|
|
||||||
let entryType: String?
|
|
||||||
let unit: String?
|
|
||||||
let servingDescription: String?
|
|
||||||
let snapshotFoodName: String?
|
|
||||||
let snapshotServingLabel: String?
|
|
||||||
let snapshotCalories: Double
|
|
||||||
let snapshotProtein: Double
|
|
||||||
let snapshotCarbs: Double
|
|
||||||
let snapshotFat: Double
|
|
||||||
let snapshotSugar: Double?
|
|
||||||
let snapshotFiber: Double?
|
|
||||||
let foodImagePath: String?
|
|
||||||
let note: String?
|
|
||||||
let entryMethod: String?
|
|
||||||
|
|
||||||
// Computed convenience accessors used by views
|
|
||||||
var foodName: String { snapshotFoodName ?? "Unknown" }
|
|
||||||
var calories: Double { snapshotCalories }
|
|
||||||
var protein: Double { snapshotProtein }
|
|
||||||
var carbs: Double { snapshotCarbs }
|
|
||||||
var fat: Double { snapshotFat }
|
|
||||||
var sugar: Double? { snapshotSugar }
|
|
||||||
var fiber: Double? { snapshotFiber }
|
|
||||||
var imageFilename: String? { foodImagePath }
|
|
||||||
var imageUrl: String? { foodImagePath }
|
|
||||||
var method: String? { entryMethod }
|
|
||||||
var loggedAt: String? { nil }
|
|
||||||
/// Convenience: raw string for the meal type (used by Color.mealColor(for:))
|
|
||||||
var mealTypeString: String { mealType.rawValue }
|
|
||||||
/// Fallback unit string for display
|
|
||||||
var unitLabel: String { unit ?? "serving" }
|
|
||||||
|
|
||||||
// No CodingKeys needed — convertFromSnakeCase handles all mappings.
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
|
||||||
id = Self.decodeStringFlex(c, "id") ?? ""
|
|
||||||
userId = Self.decodeStringFlex(c, "userId")
|
|
||||||
foodId = Self.decodeStringFlex(c, "foodId")
|
|
||||||
mealType = (try? c.decode(MealType.self, forKey: AnyCodingKey("mealType"))) ?? .snack
|
|
||||||
quantity = Self.decodeDoubleFlex(c, "quantity") ?? 1.0
|
|
||||||
entryDate = (try? c.decode(String.self, forKey: AnyCodingKey("entryDate"))) ?? ""
|
|
||||||
entryType = try? c.decode(String.self, forKey: AnyCodingKey("entryType"))
|
|
||||||
unit = try? c.decode(String.self, forKey: AnyCodingKey("unit"))
|
|
||||||
servingDescription = try? c.decode(String.self, forKey: AnyCodingKey("servingDescription"))
|
|
||||||
snapshotFoodName = try? c.decode(String.self, forKey: AnyCodingKey("snapshotFoodName"))
|
|
||||||
snapshotServingLabel = try? c.decode(String.self, forKey: AnyCodingKey("snapshotServingLabel"))
|
|
||||||
snapshotCalories = Self.decodeDoubleFlex(c, "snapshotCalories") ?? 0
|
|
||||||
snapshotProtein = Self.decodeDoubleFlex(c, "snapshotProtein") ?? 0
|
|
||||||
snapshotCarbs = Self.decodeDoubleFlex(c, "snapshotCarbs") ?? 0
|
|
||||||
snapshotFat = Self.decodeDoubleFlex(c, "snapshotFat") ?? 0
|
|
||||||
snapshotSugar = Self.decodeDoubleFlex(c, "snapshotSugar")
|
|
||||||
snapshotFiber = Self.decodeDoubleFlex(c, "snapshotFiber")
|
|
||||||
foodImagePath = try? c.decode(String.self, forKey: AnyCodingKey("foodImagePath"))
|
|
||||||
note = try? c.decode(String.self, forKey: AnyCodingKey("note"))
|
|
||||||
entryMethod = try? c.decode(String.self, forKey: AnyCodingKey("entryMethod"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var c = encoder.container(keyedBy: AnyCodingKey.self)
|
|
||||||
try c.encode(id, forKey: AnyCodingKey("id"))
|
|
||||||
try c.encodeIfPresent(userId, forKey: AnyCodingKey("userId"))
|
|
||||||
try c.encodeIfPresent(foodId, forKey: AnyCodingKey("foodId"))
|
|
||||||
try c.encode(mealType, forKey: AnyCodingKey("mealType"))
|
|
||||||
try c.encode(quantity, forKey: AnyCodingKey("quantity"))
|
|
||||||
try c.encode(entryDate, forKey: AnyCodingKey("entryDate"))
|
|
||||||
try c.encodeIfPresent(snapshotFoodName, forKey: AnyCodingKey("snapshotFoodName"))
|
|
||||||
try c.encode(snapshotCalories, forKey: AnyCodingKey("snapshotCalories"))
|
|
||||||
try c.encode(snapshotProtein, forKey: AnyCodingKey("snapshotProtein"))
|
|
||||||
try c.encode(snapshotCarbs, forKey: AnyCodingKey("snapshotCarbs"))
|
|
||||||
try c.encode(snapshotFat, forKey: AnyCodingKey("snapshotFat"))
|
|
||||||
try c.encodeIfPresent(snapshotSugar, forKey: AnyCodingKey("snapshotSugar"))
|
|
||||||
try c.encodeIfPresent(snapshotFiber, forKey: AnyCodingKey("snapshotFiber"))
|
|
||||||
try c.encodeIfPresent(foodImagePath, forKey: AnyCodingKey("foodImagePath"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flexible decoding helpers
|
|
||||||
private static func decodeDoubleFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
|
|
||||||
let k = AnyCodingKey(key)
|
|
||||||
if let v = try? c.decode(Double.self, forKey: k) { return v }
|
|
||||||
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
|
|
||||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeStringFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> String? {
|
|
||||||
let k = AnyCodingKey(key)
|
|
||||||
if let v = try? c.decode(String.self, forKey: k) { return v }
|
|
||||||
if let v = try? c.decode(Int.self, forKey: k) { return String(v) }
|
|
||||||
if let v = try? c.decode(Double.self, forKey: k) { return String(Int(v)) }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Food Item
|
|
||||||
// API: id (UUID string), name, brand, base_unit, calories_per_base, protein_per_base, etc.
|
|
||||||
|
|
||||||
struct FoodItem: Identifiable, Codable {
|
|
||||||
let id: String
|
|
||||||
let name: String
|
|
||||||
let brand: String?
|
|
||||||
let baseUnit: String?
|
|
||||||
let caloriesPerBase: Double
|
|
||||||
let proteinPerBase: Double
|
|
||||||
let carbsPerBase: Double
|
|
||||||
let fatPerBase: Double
|
|
||||||
let sugarPerBase: Double?
|
|
||||||
let fiberPerBase: Double?
|
|
||||||
let status: String?
|
|
||||||
let imageFilename: String?
|
|
||||||
let favorite: Bool?
|
|
||||||
|
|
||||||
// Computed convenience accessors (used by views that reference .calories, .protein, etc.)
|
|
||||||
var calories: Double { caloriesPerBase }
|
|
||||||
var protein: Double { proteinPerBase }
|
|
||||||
var carbs: Double { carbsPerBase }
|
|
||||||
var fat: Double { fatPerBase }
|
|
||||||
var sugar: Double? { sugarPerBase }
|
|
||||||
var fiber: Double? { fiberPerBase }
|
|
||||||
var servingSize: String? { baseUnit }
|
|
||||||
var imageUrl: String? { imageFilename }
|
|
||||||
var displayUnit: String { baseUnit ?? "serving" }
|
|
||||||
var displayInfo: String {
|
|
||||||
let cal = Int(caloriesPerBase)
|
|
||||||
return "\(cal) cal per \(displayUnit)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func scaledCalories(quantity: Double) -> Double { caloriesPerBase * quantity }
|
|
||||||
func scaledProtein(quantity: Double) -> Double { proteinPerBase * quantity }
|
|
||||||
func scaledCarbs(quantity: Double) -> Double { carbsPerBase * quantity }
|
|
||||||
func scaledFat(quantity: Double) -> Double { fatPerBase * quantity }
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
|
||||||
// id can be String or Int
|
|
||||||
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
|
|
||||||
id = v
|
|
||||||
} else if let v = try? c.decode(Int.self, forKey: AnyCodingKey("id")) {
|
|
||||||
id = String(v)
|
|
||||||
} else {
|
|
||||||
id = ""
|
|
||||||
}
|
|
||||||
name = (try? c.decode(String.self, forKey: AnyCodingKey("name"))) ?? ""
|
|
||||||
brand = try? c.decode(String.self, forKey: AnyCodingKey("brand"))
|
|
||||||
baseUnit = try? c.decode(String.self, forKey: AnyCodingKey("baseUnit"))
|
|
||||||
caloriesPerBase = Self.doubleFlex(c, "caloriesPerBase")
|
|
||||||
proteinPerBase = Self.doubleFlex(c, "proteinPerBase")
|
|
||||||
carbsPerBase = Self.doubleFlex(c, "carbsPerBase")
|
|
||||||
fatPerBase = Self.doubleFlex(c, "fatPerBase")
|
|
||||||
sugarPerBase = Self.doubleFlexOpt(c, "sugarPerBase")
|
|
||||||
fiberPerBase = Self.doubleFlexOpt(c, "fiberPerBase")
|
|
||||||
status = try? c.decode(String.self, forKey: AnyCodingKey("status"))
|
|
||||||
imageFilename = try? c.decode(String.self, forKey: AnyCodingKey("imageFilename"))
|
|
||||||
favorite = try? c.decode(Bool.self, forKey: AnyCodingKey("favorite"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func doubleFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double {
|
|
||||||
let k = AnyCodingKey(key)
|
|
||||||
if let v = try? c.decode(Double.self, forKey: k) { return v }
|
|
||||||
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
|
|
||||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func doubleFlexOpt(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
|
|
||||||
let k = AnyCodingKey(key)
|
|
||||||
if let v = try? c.decode(Double.self, forKey: k) { return v }
|
|
||||||
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
|
|
||||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Daily Goal
|
|
||||||
// API: id (UUID), calories, protein, carbs, fat, sugar, fiber, is_active
|
|
||||||
|
|
||||||
struct DailyGoal: Codable {
|
|
||||||
let id: String?
|
|
||||||
let calories: Double
|
|
||||||
let protein: Double
|
|
||||||
let carbs: Double
|
|
||||||
let fat: Double
|
|
||||||
let sugar: Double?
|
|
||||||
let fiber: Double?
|
|
||||||
let isActive: Int?
|
|
||||||
|
|
||||||
static let defaultGoal = DailyGoal()
|
|
||||||
|
|
||||||
init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
|
|
||||||
self.id = nil
|
|
||||||
self.calories = calories
|
|
||||||
self.protein = protein
|
|
||||||
self.carbs = carbs
|
|
||||||
self.fat = fat
|
|
||||||
self.sugar = sugar
|
|
||||||
self.fiber = fiber
|
|
||||||
self.isActive = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
|
||||||
id = try? c.decode(String.self, forKey: AnyCodingKey("id"))
|
|
||||||
calories = Self.doubleFlex(c, "calories", default: 2000)
|
|
||||||
protein = Self.doubleFlex(c, "protein", default: 150)
|
|
||||||
carbs = Self.doubleFlex(c, "carbs", default: 250)
|
|
||||||
fat = Self.doubleFlex(c, "fat", default: 65)
|
|
||||||
sugar = Self.doubleFlexOpt(c, "sugar")
|
|
||||||
fiber = Self.doubleFlexOpt(c, "fiber")
|
|
||||||
if let v = try? c.decode(Int.self, forKey: AnyCodingKey("isActive")) {
|
|
||||||
isActive = v
|
|
||||||
} else if let v = try? c.decode(Double.self, forKey: AnyCodingKey("isActive")) {
|
|
||||||
isActive = Int(v)
|
|
||||||
} else {
|
|
||||||
isActive = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func doubleFlex(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String, default def: Double) -> Double {
|
|
||||||
let k = AnyCodingKey(key)
|
|
||||||
if let v = try? c.decode(Double.self, forKey: k) { return v }
|
|
||||||
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
|
|
||||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func doubleFlexOpt(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
|
|
||||||
let k = AnyCodingKey(key)
|
|
||||||
if let v = try? c.decode(Double.self, forKey: k) { return v }
|
|
||||||
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
|
|
||||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Meal Template
|
|
||||||
// API: id (UUID), name, meal_type, total_calories, total_protein, total_carbs, total_fat, item_count
|
|
||||||
|
|
||||||
struct MealTemplate: Identifiable, Codable {
|
|
||||||
let id: String
|
|
||||||
let name: String
|
|
||||||
let mealType: MealType
|
|
||||||
let totalCalories: Double?
|
|
||||||
let totalProtein: Double?
|
|
||||||
let totalCarbs: Double?
|
|
||||||
let totalFat: Double?
|
|
||||||
let itemCount: Int?
|
|
||||||
|
|
||||||
// Convenience accessors used by views
|
|
||||||
var calories: Double { totalCalories ?? 0 }
|
|
||||||
var protein: Double? { totalProtein }
|
|
||||||
var carbs: Double? { totalCarbs }
|
|
||||||
var fat: Double? { totalFat }
|
|
||||||
var itemsCount: Int? { itemCount }
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: AnyCodingKey.self)
|
|
||||||
if let v = try? c.decode(String.self, forKey: AnyCodingKey("id")) {
|
|
||||||
id = v
|
|
||||||
} else if let v = try? c.decode(Int.self, forKey: AnyCodingKey("id")) {
|
|
||||||
id = String(v)
|
|
||||||
} else {
|
|
||||||
id = ""
|
|
||||||
}
|
|
||||||
name = (try? c.decode(String.self, forKey: AnyCodingKey("name"))) ?? ""
|
|
||||||
mealType = (try? c.decode(MealType.self, forKey: AnyCodingKey("mealType"))) ?? .snack
|
|
||||||
totalCalories = Self.doubleFlexOpt(c, "totalCalories")
|
|
||||||
totalProtein = Self.doubleFlexOpt(c, "totalProtein")
|
|
||||||
totalCarbs = Self.doubleFlexOpt(c, "totalCarbs")
|
|
||||||
totalFat = Self.doubleFlexOpt(c, "totalFat")
|
|
||||||
if let v = try? c.decode(Int.self, forKey: AnyCodingKey("itemCount")) {
|
|
||||||
itemCount = v
|
|
||||||
} else if let v = try? c.decode(Double.self, forKey: AnyCodingKey("itemCount")) {
|
|
||||||
itemCount = Int(v)
|
|
||||||
} else {
|
|
||||||
itemCount = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func doubleFlexOpt(_ c: KeyedDecodingContainer<AnyCodingKey>, _ key: String) -> Double? {
|
|
||||||
let k = AnyCodingKey(key)
|
|
||||||
if let v = try? c.decode(Double.self, forKey: k) { return v }
|
|
||||||
if let v = try? c.decode(Int.self, forKey: k) { return Double(v) }
|
|
||||||
if let v = try? c.decode(String.self, forKey: k), let d = Double(v) { return d }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Requests
|
|
||||||
// Note: APIClient uses encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
||||||
// so camelCase properties are auto-converted to snake_case in JSON.
|
|
||||||
|
|
||||||
struct CreateEntryRequest: Encodable {
|
|
||||||
let foodId: String?
|
|
||||||
let quantity: Double
|
|
||||||
let unit: String?
|
|
||||||
let mealType: String
|
|
||||||
let entryDate: String
|
|
||||||
let entryMethod: String?
|
|
||||||
let source: String?
|
|
||||||
|
|
||||||
// Additional fields for manual entries without a foodId
|
|
||||||
let foodName: String?
|
|
||||||
let calories: Double?
|
|
||||||
let protein: Double?
|
|
||||||
let carbs: Double?
|
|
||||||
let fat: Double?
|
|
||||||
let sugar: Double?
|
|
||||||
let fiber: Double?
|
|
||||||
|
|
||||||
/// Convenience init for adding from food library (foodId-based)
|
|
||||||
init(foodId: String, quantity: Double, unit: String, mealType: String, entryDate: String, entryMethod: String? = "manual", source: String? = "ios_app") {
|
|
||||||
self.foodId = foodId
|
|
||||||
self.quantity = quantity
|
|
||||||
self.unit = unit
|
|
||||||
self.mealType = mealType
|
|
||||||
self.entryDate = entryDate
|
|
||||||
self.entryMethod = entryMethod
|
|
||||||
self.source = source
|
|
||||||
self.foodName = nil
|
|
||||||
self.calories = nil
|
|
||||||
self.protein = nil
|
|
||||||
self.carbs = nil
|
|
||||||
self.fat = nil
|
|
||||||
self.sugar = nil
|
|
||||||
self.fiber = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience init for manual entries (with inline macros)
|
|
||||||
init(foodId: String? = nil, foodName: String, mealType: String, quantity: Double, entryDate: String, calories: Double, protein: Double, carbs: Double, fat: Double, sugar: Double? = nil, fiber: Double? = nil) {
|
|
||||||
self.foodId = foodId
|
|
||||||
self.foodName = foodName
|
|
||||||
self.mealType = mealType
|
|
||||||
self.quantity = quantity
|
|
||||||
self.entryDate = entryDate
|
|
||||||
self.calories = calories
|
|
||||||
self.protein = protein
|
|
||||||
self.carbs = carbs
|
|
||||||
self.fat = fat
|
|
||||||
self.sugar = sugar
|
|
||||||
self.fiber = fiber
|
|
||||||
self.unit = nil
|
|
||||||
self.entryMethod = "manual"
|
|
||||||
self.source = "ios_app"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UpdateEntryRequest: Encodable {
|
|
||||||
let quantity: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UpdateGoalsRequest: Encodable {
|
|
||||||
let calories: Double
|
|
||||||
let protein: Double
|
|
||||||
let carbs: Double
|
|
||||||
let fat: Double
|
|
||||||
let sugar: Double
|
|
||||||
let fiber: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fitness Draft (AI Chat)
|
|
||||||
|
|
||||||
struct FitnessDraft {
|
|
||||||
let foodName: String
|
|
||||||
let mealType: String
|
|
||||||
let calories: Double
|
|
||||||
let protein: Double
|
|
||||||
let carbs: Double
|
|
||||||
let fat: Double
|
|
||||||
let sugar: Double?
|
|
||||||
let fiber: Double?
|
|
||||||
let quantity: Double
|
|
||||||
|
|
||||||
init?(from dict: [String: Any]) {
|
|
||||||
guard let name = dict["food_name"] as? String else { return nil }
|
|
||||||
foodName = name
|
|
||||||
mealType = (dict["meal_type"] as? String) ?? "snack"
|
|
||||||
calories = Self.flexDouble(dict["calories"])
|
|
||||||
protein = Self.flexDouble(dict["protein"])
|
|
||||||
carbs = Self.flexDouble(dict["carbs"])
|
|
||||||
fat = Self.flexDouble(dict["fat"])
|
|
||||||
sugar = dict["sugar"].flatMap { Self.flexDoubleOpt($0) }
|
|
||||||
fiber = dict["fiber"].flatMap { Self.flexDoubleOpt($0) }
|
|
||||||
quantity = Self.flexDouble(dict["quantity"], default: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func flexDouble(_ val: Any?, default def: Double = 0) -> Double {
|
|
||||||
if let v = val as? Double { return v }
|
|
||||||
if let v = val as? Int { return Double(v) }
|
|
||||||
if let v = val as? NSNumber { return v.doubleValue }
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func flexDoubleOpt(_ val: Any) -> Double? {
|
|
||||||
if let v = val as? Double { return v }
|
|
||||||
if let v = val as? Int { return Double(v) }
|
|
||||||
if let v = val as? NSNumber { return v.doubleValue }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Source Link
|
|
||||||
|
|
||||||
struct SourceLink: Identifiable {
|
|
||||||
let id: String
|
|
||||||
let title: String
|
|
||||||
let type: String
|
|
||||||
let href: String
|
|
||||||
|
|
||||||
init?(from dict: [String: Any]) {
|
|
||||||
guard let id = dict["id"] as? String,
|
|
||||||
let title = dict["title"] as? String else { return nil }
|
|
||||||
self.id = id
|
|
||||||
self.title = title
|
|
||||||
self.type = (dict["type"] as? String) ?? ""
|
|
||||||
self.href = (dict["href"] as? String) ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AnyCodingKey (flexible key lookup)
|
|
||||||
|
|
||||||
struct AnyCodingKey: CodingKey {
|
|
||||||
var stringValue: String
|
|
||||||
var intValue: Int?
|
|
||||||
|
|
||||||
init(_ string: String) {
|
|
||||||
self.stringValue = string
|
|
||||||
self.intValue = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(stringValue: String) {
|
|
||||||
self.stringValue = stringValue
|
|
||||||
self.intValue = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(intValue: Int) {
|
|
||||||
self.stringValue = String(intValue)
|
|
||||||
self.intValue = intValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Meal Group (used by TodayView)
|
|
||||||
|
|
||||||
struct MealGroup: Identifiable {
|
|
||||||
let meal: MealType
|
|
||||||
let entries: [FoodEntry]
|
|
||||||
|
|
||||||
var id: String { meal.rawValue }
|
|
||||||
|
|
||||||
var totalCalories: Double {
|
|
||||||
entries.reduce(0) { $0 + $1.calories }
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalProtein: Double {
|
|
||||||
entries.reduce(0) { $0 + $1.protein }
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalCarbs: Double {
|
|
||||||
entries.reduce(0) { $0 + $1.carbs }
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalFat: Double {
|
|
||||||
entries.reduce(0) { $0 + $1.fat }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,128 +4,78 @@ import Foundation
|
|||||||
final class FitnessRepository {
|
final class FitnessRepository {
|
||||||
static let shared = FitnessRepository()
|
static let shared = FitnessRepository()
|
||||||
|
|
||||||
|
private let api = FitnessAPI()
|
||||||
|
|
||||||
|
var entries: [FoodEntry] = []
|
||||||
|
var goal: DailyGoal?
|
||||||
|
var foods: [Food] = []
|
||||||
|
var recentFoods: [RecentFood] = []
|
||||||
|
var templates: [MealTemplate] = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var error: String?
|
var error: String?
|
||||||
|
|
||||||
private let api = FitnessAPI()
|
|
||||||
|
|
||||||
// Caches
|
|
||||||
private var entriesCache: [String: [FoodEntry]] = [:]
|
|
||||||
private var goalsCache: [String: DailyGoal] = [:]
|
|
||||||
private var recentFoodsCache: [FoodItem]?
|
|
||||||
private var templatesCache: [MealTemplate]?
|
|
||||||
|
|
||||||
// MARK: - Entries
|
// MARK: - Entries
|
||||||
|
|
||||||
func entries(for date: String, forceRefresh: Bool = false) async throws -> [FoodEntry] {
|
func loadEntries(date: String) async {
|
||||||
if !forceRefresh, let cached = entriesCache[date] {
|
do {
|
||||||
return cached
|
entries = try await api.getEntries(date: date)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
let result = try await api.getEntries(date: date)
|
|
||||||
entriesCache[date] = result
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Goals
|
func loadGoal(date: String) async {
|
||||||
|
do {
|
||||||
func goals(for date: String) async throws -> DailyGoal {
|
goal = try await api.getGoalsForDate(date: date)
|
||||||
if let cached = goalsCache[date] {
|
} catch {
|
||||||
return cached
|
goal = nil
|
||||||
}
|
}
|
||||||
let result = try await api.getGoals(date: date)
|
|
||||||
goalsCache[date] = result
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Create / Update / Delete Entries
|
func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry {
|
||||||
|
let entry = try await api.createEntry(request)
|
||||||
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
|
await loadEntries(date: request.entryDate)
|
||||||
let entry = try await api.createEntry(req)
|
|
||||||
// Invalidate cache for the entry date
|
|
||||||
entriesCache.removeValue(forKey: entry.entryDate)
|
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias kept for backward compatibility
|
|
||||||
func addEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
|
|
||||||
try await createEntry(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateEntry(id: String, request: UpdateEntryRequest, date: String) async throws -> FoodEntry {
|
|
||||||
let updated = try await api.updateEntry(id: id, quantity: request.quantity)
|
|
||||||
entriesCache.removeValue(forKey: date)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteEntry(id: String, date: String) async throws {
|
func deleteEntry(id: String, date: String) async throws {
|
||||||
try await api.deleteEntry(id: id)
|
_ = try await api.deleteEntry(id: id)
|
||||||
entriesCache[date]?.removeAll { $0.id == id }
|
await loadEntries(date: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Food Search
|
// MARK: - Foods
|
||||||
|
|
||||||
func searchFoods(query: String) async throws -> [FoodItem] {
|
func loadFoods(limit: Int = 100) async {
|
||||||
try await api.searchFoods(query: query)
|
do {
|
||||||
|
foods = try await api.getFoods(limit: limit)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recentFoods(forceRefresh: Bool = false) async throws -> [FoodItem] {
|
func searchFoods(query: String, limit: Int = 20) async throws -> [Food] {
|
||||||
if !forceRefresh, let cached = recentFoodsCache {
|
try await api.searchFoods(query: query, limit: limit)
|
||||||
return cached
|
}
|
||||||
|
|
||||||
|
func loadRecentFoods(limit: Int = 20) async {
|
||||||
|
do {
|
||||||
|
recentFoods = try await api.getRecentFoods(limit: limit)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
let result = try await api.getRecentFoods()
|
|
||||||
recentFoodsCache = result
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Templates
|
// MARK: - Templates
|
||||||
|
|
||||||
func templates(forceRefresh: Bool = false) async throws -> [MealTemplate] {
|
func loadTemplates() async {
|
||||||
if !forceRefresh, let cached = templatesCache {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
let result = try await api.getTemplates()
|
|
||||||
templatesCache = result
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func logTemplate(id: String, date: String) async throws {
|
|
||||||
try await api.logTemplate(id: id, date: date)
|
|
||||||
// Invalidate entries cache for that date so it reloads
|
|
||||||
entriesCache.removeValue(forKey: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Legacy loadDay (used by GoalsViewModel)
|
|
||||||
|
|
||||||
/// Kept for GoalsViewModel compatibility — loads entries + goals into the old-style properties.
|
|
||||||
var entries: [FoodEntry] = []
|
|
||||||
var goals: DailyGoal = DailyGoal()
|
|
||||||
|
|
||||||
func loadDay(date: String) async {
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
do {
|
do {
|
||||||
async let e = api.getEntries(date: date)
|
templates = try await api.getTemplates()
|
||||||
async let g = api.getGoals(date: date)
|
|
||||||
entries = try await e
|
|
||||||
goals = try await g
|
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Helpers (legacy)
|
func logTemplate(id: String, mealType: String, entryDate: String) async throws -> TemplateLogResponse {
|
||||||
|
try await api.logTemplate(id: id, mealType: mealType, entryDate: entryDate)
|
||||||
var totalCalories: Double { entries.reduce(0) { $0 + $1.calories * $1.quantity } }
|
|
||||||
var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } }
|
|
||||||
var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } }
|
|
||||||
var totalFat: Double { entries.reduce(0) { $0 + $1.fat * $1.quantity } }
|
|
||||||
|
|
||||||
func entriesForMeal(_ meal: MealType) -> [FoodEntry] {
|
|
||||||
entries.filter { $0.mealType == meal }
|
|
||||||
}
|
|
||||||
|
|
||||||
func mealCalories(_ meal: MealType) -> Double {
|
|
||||||
entriesForMeal(meal).reduce(0) { $0 + $1.calories * $1.quantity }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +1,59 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor @Observable
|
@Observable
|
||||||
final class FoodSearchViewModel {
|
final class FoodSearchViewModel {
|
||||||
var searchText = ""
|
|
||||||
var searchResults: [FoodItem] = []
|
|
||||||
var recentFoods: [FoodItem] = []
|
|
||||||
var isSearching = false
|
|
||||||
var isLoadingRecent = false
|
|
||||||
var errorMessage: String?
|
|
||||||
|
|
||||||
// Add food sheet state
|
|
||||||
var selectedFood: FoodItem?
|
|
||||||
var showAddSheet = false
|
|
||||||
var addQuantity: Double = 1
|
|
||||||
var addMealType: MealType = .guess()
|
|
||||||
var isAddingFood = false
|
|
||||||
|
|
||||||
private let repo = FitnessRepository.shared
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
|
var searchText = ""
|
||||||
|
var searchResults: [Food] = []
|
||||||
|
var recentFoods: [RecentFood] = []
|
||||||
|
var allFoods: [Food] = []
|
||||||
|
var isSearching = false
|
||||||
|
var isLoadingInitial = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
var displayedFoods: [FoodItem] {
|
func loadInitial() async {
|
||||||
if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
isLoadingInitial = true
|
||||||
return recentFoods
|
async let recentTask: () = loadRecent()
|
||||||
}
|
async let allTask: () = loadAll()
|
||||||
return searchResults
|
_ = await (recentTask, allTask)
|
||||||
|
isLoadingInitial = false
|
||||||
}
|
}
|
||||||
|
|
||||||
var isShowingRecent: Bool {
|
private func loadRecent() async {
|
||||||
searchText.trimmingCharacters(in: .whitespaces).isEmpty
|
await repo.loadRecentFoods(limit: 20)
|
||||||
|
recentFoods = repo.recentFoods
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadRecent() async {
|
private func loadAll() async {
|
||||||
isLoadingRecent = true
|
await repo.loadFoods(limit: 200)
|
||||||
do {
|
allFoods = repo.foods
|
||||||
recentFoods = try await repo.recentFoods(forceRefresh: true)
|
|
||||||
} catch {
|
|
||||||
// Silent failure for recent foods
|
|
||||||
}
|
|
||||||
isLoadingRecent = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func search() {
|
func search() {
|
||||||
let query = searchText.trimmingCharacters(in: .whitespaces)
|
|
||||||
|
|
||||||
// Cancel previous search
|
|
||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
|
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !query.isEmpty else {
|
guard !query.isEmpty else {
|
||||||
searchResults = []
|
searchResults = []
|
||||||
isSearching = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard query.count >= 2 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSearching = true
|
isSearching = true
|
||||||
searchTask = Task {
|
searchTask = Task {
|
||||||
// Debounce
|
|
||||||
try? await Task.sleep(for: .milliseconds(300))
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let results = try await repo.searchFoods(query: query)
|
let results = try await repo.searchFoods(query: query, limit: 20)
|
||||||
guard !Task.isCancelled else { return }
|
if !Task.isCancelled {
|
||||||
searchResults = results
|
searchResults = results
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
if !Task.isCancelled {
|
||||||
errorMessage = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isSearching = false
|
isSearching = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectFood(_ food: FoodItem) {
|
|
||||||
selectedFood = food
|
|
||||||
addQuantity = 1
|
|
||||||
addMealType = .guess()
|
|
||||||
showAddSheet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func addFood(date: String, onComplete: @escaping () -> Void) async {
|
|
||||||
guard let food = selectedFood else { return }
|
|
||||||
isAddingFood = true
|
|
||||||
|
|
||||||
let request = CreateEntryRequest(
|
|
||||||
foodId: food.id,
|
|
||||||
quantity: addQuantity,
|
|
||||||
unit: food.baseUnit ?? "serving",
|
|
||||||
mealType: addMealType.rawValue,
|
|
||||||
entryDate: date,
|
|
||||||
entryMethod: "manual",
|
|
||||||
source: "ios_app"
|
|
||||||
)
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try await repo.createEntry(request)
|
|
||||||
showAddSheet = false
|
|
||||||
selectedFood = nil
|
|
||||||
onComplete()
|
|
||||||
} catch {
|
|
||||||
errorMessage = "Failed to add food: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
isAddingFood = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor @Observable
|
@Observable
|
||||||
final class GoalsViewModel {
|
final class GoalsViewModel {
|
||||||
var goal: DailyGoal = DailyGoal()
|
private let api = FitnessAPI()
|
||||||
var isLoading = true
|
|
||||||
var errorMessage: String?
|
|
||||||
|
|
||||||
private let repo = FitnessRepository.shared
|
var goal: DailyGoal?
|
||||||
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
do {
|
||||||
await repo.loadDay(date: Date().apiDateString)
|
goal = try await api.getGoalsForDate(date: Date().apiDateString)
|
||||||
goal = repo.goals
|
} catch {
|
||||||
if let err = repo.error {
|
self.error = "No active goal found"
|
||||||
errorMessage = err
|
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,32 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor @Observable
|
@Observable
|
||||||
final class TemplatesViewModel {
|
final class TemplatesViewModel {
|
||||||
var templates: [MealTemplate] = []
|
|
||||||
var isLoading = true
|
|
||||||
var errorMessage: String?
|
|
||||||
var isLogging = false
|
|
||||||
var loggedTemplateId: String?
|
|
||||||
|
|
||||||
private let repo = FitnessRepository.shared
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
|
var templates: [MealTemplate] = []
|
||||||
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
await repo.loadTemplates()
|
||||||
|
templates = repo.templates
|
||||||
do {
|
|
||||||
templates = try await repo.templates(forceRefresh: true)
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func logTemplate(_ template: MealTemplate, date: String, onComplete: @escaping () -> Void) async {
|
func logTemplate(_ template: MealTemplate, mealType: String, entryDate: String) async throws -> TemplateLogResponse {
|
||||||
isLogging = true
|
try await repo.logTemplate(id: template.id, mealType: mealType, entryDate: entryDate)
|
||||||
loggedTemplateId = template.id
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await repo.logTemplate(id: template.id, date: date)
|
|
||||||
loggedTemplateId = nil
|
|
||||||
onComplete()
|
|
||||||
} catch {
|
|
||||||
errorMessage = "Failed to log template: \(error.localizedDescription)"
|
|
||||||
loggedTemplateId = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLogging = false
|
var groupedByMealType: [(String, [MealTemplate])] {
|
||||||
|
let grouped = Dictionary(grouping: templates) { t in
|
||||||
|
t.mealType ?? "other"
|
||||||
|
}
|
||||||
|
let order = ["breakfast", "lunch", "dinner", "snack", "other"]
|
||||||
|
return order.compactMap { key in
|
||||||
|
guard let items = grouped[key], !items.isEmpty else { return nil }
|
||||||
|
return (key.capitalized, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupedTemplates: [String: [MealTemplate]] {
|
|
||||||
Dictionary(grouping: templates, by: { $0.mealType.rawValue })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor @Observable
|
@Observable
|
||||||
final class TodayViewModel {
|
final class TodayViewModel {
|
||||||
var entries: [FoodEntry] = []
|
|
||||||
var goal: DailyGoal = DailyGoal()
|
|
||||||
var selectedDate: Date = Date()
|
|
||||||
var isLoading = true
|
|
||||||
var errorMessage: String?
|
|
||||||
var expandedMeals: Set<String> = Set(MealType.allCases.map(\.rawValue))
|
|
||||||
|
|
||||||
private let repo = FitnessRepository.shared
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
var selectedDate = Date()
|
||||||
|
var entries: [FoodEntry] = []
|
||||||
|
var goal: DailyGoal?
|
||||||
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
var dateString: String {
|
var dateString: String {
|
||||||
selectedDate.apiDateString
|
selectedDate.apiDateString
|
||||||
}
|
}
|
||||||
|
|
||||||
var mealGroups: [MealGroup] {
|
var isToday: Bool {
|
||||||
MealType.allCases.map { meal in
|
Calendar.current.isDateInToday(selectedDate)
|
||||||
MealGroup(
|
}
|
||||||
meal: meal,
|
|
||||||
entries: entries.filter { $0.mealType == meal }
|
var displayDateString: String {
|
||||||
)
|
if isToday { return "Today" }
|
||||||
|
if Calendar.current.isDateInYesterday(selectedDate) { return "Yesterday" }
|
||||||
|
if Calendar.current.isDateInTomorrow(selectedDate) { return "Tomorrow" }
|
||||||
|
return selectedDate.displayString
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grouped entries
|
||||||
|
|
||||||
|
var mealGroups: [(MealType, [FoodEntry])] {
|
||||||
|
let grouped = Dictionary(grouping: entries) { entry in
|
||||||
|
MealType(rawValue: entry.mealType) ?? .snack
|
||||||
|
}
|
||||||
|
return MealType.allCases.compactMap { meal in
|
||||||
|
guard let items = grouped[meal], !items.isEmpty else { return nil }
|
||||||
|
return (meal, items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Totals
|
||||||
|
|
||||||
var totalCalories: Double {
|
var totalCalories: Double {
|
||||||
entries.reduce(0) { $0 + $1.calories }
|
entries.reduce(0) { $0 + $1.calories }
|
||||||
}
|
}
|
||||||
@@ -42,70 +55,61 @@ final class TodayViewModel {
|
|||||||
entries.reduce(0) { $0 + $1.fat }
|
entries.reduce(0) { $0 + $1.fat }
|
||||||
}
|
}
|
||||||
|
|
||||||
var caloriesRemaining: Double {
|
var calorieGoal: Double {
|
||||||
max(goal.calories - totalCalories, 0)
|
goal?.calories ?? 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
var proteinGoal: Double {
|
||||||
|
goal?.protein ?? 150
|
||||||
|
}
|
||||||
|
|
||||||
|
var carbsGoal: Double {
|
||||||
|
goal?.carbs ?? 200
|
||||||
|
}
|
||||||
|
|
||||||
|
var fatGoal: Double {
|
||||||
|
goal?.fat ?? 65
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
error = nil
|
||||||
|
async let entriesTask: () = loadEntries()
|
||||||
do {
|
async let goalTask: () = loadGoal()
|
||||||
async let entriesTask = repo.entries(for: dateString, forceRefresh: true)
|
_ = await (entriesTask, goalTask)
|
||||||
async let goalsTask = repo.goals(for: dateString)
|
|
||||||
|
|
||||||
entries = try await entriesTask
|
|
||||||
goal = try await goalsTask
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func goToNextDay() {
|
func loadEntries() async {
|
||||||
selectedDate = selectedDate.adding(days: 1)
|
await repo.loadEntries(date: dateString)
|
||||||
Task { await load() }
|
entries = repo.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGoal() async {
|
||||||
|
await repo.loadGoal(date: dateString)
|
||||||
|
goal = repo.goal
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(_ entry: FoodEntry) async {
|
||||||
|
do {
|
||||||
|
try await repo.deleteEntry(id: entry.id, date: dateString)
|
||||||
|
entries = repo.entries
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func goToPreviousDay() {
|
func goToPreviousDay() {
|
||||||
selectedDate = selectedDate.adding(days: -1)
|
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
|
||||||
Task { await load() }
|
}
|
||||||
|
|
||||||
|
func goToNextDay() {
|
||||||
|
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
func goToToday() {
|
func goToToday() {
|
||||||
selectedDate = Date()
|
selectedDate = Date()
|
||||||
Task { await load() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleMeal(_ meal: String) {
|
|
||||||
if expandedMeals.contains(meal) {
|
|
||||||
expandedMeals.remove(meal)
|
|
||||||
} else {
|
|
||||||
expandedMeals.insert(meal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteEntry(_ entry: FoodEntry) async {
|
|
||||||
// Optimistic removal
|
|
||||||
entries.removeAll { $0.id == entry.id }
|
|
||||||
do {
|
|
||||||
try await repo.deleteEntry(id: entry.id, date: dateString)
|
|
||||||
} catch {
|
|
||||||
// Reload on failure
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateEntryQuantity(id: String, quantity: Double) async {
|
|
||||||
let request = UpdateEntryRequest(quantity: quantity)
|
|
||||||
do {
|
|
||||||
_ = try await repo.updateEntry(id: id, request: request, date: dateString)
|
|
||||||
await load()
|
|
||||||
} catch {
|
|
||||||
errorMessage = "Failed to update entry"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,175 +1,109 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AddFoodSheet: View {
|
struct AddFoodSheet: View {
|
||||||
let food: FoodItem
|
|
||||||
@Binding var quantity: Double
|
|
||||||
@Binding var mealType: MealType
|
|
||||||
let isAdding: Bool
|
|
||||||
let onAdd: () -> Void
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var quantityText: String = "1"
|
let food: Food
|
||||||
|
|
||||||
|
@State private var quantity: Double = 1.0
|
||||||
|
@State private var selectedMeal: MealType = .snack
|
||||||
|
@State private var selectedServingId: String?
|
||||||
|
@State private var entryDate = Date()
|
||||||
|
@State private var isAdding = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private var defaultServing: FoodServing? {
|
||||||
|
food.servings?.first(where: { $0.isDefault == 1 }) ?? food.servings?.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var multiplier: Double {
|
||||||
|
if let servingId = selectedServingId,
|
||||||
|
let serving = food.servings?.first(where: { $0.id == servingId }) {
|
||||||
|
return quantity * serving.amountInBase
|
||||||
|
}
|
||||||
|
return quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
private var previewCalories: Double { food.caloriesPerBase * multiplier }
|
||||||
|
private var previewProtein: Double { food.proteinPerBase * multiplier }
|
||||||
|
private var previewCarbs: Double { food.carbsPerBase * multiplier }
|
||||||
|
private var previewFat: Double { food.fatPerBase * multiplier }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 24) {
|
ScrollView {
|
||||||
// Food info header
|
VStack(spacing: 20) {
|
||||||
foodHeader
|
// Food name
|
||||||
|
VStack(spacing: 4) {
|
||||||
// Quantity input
|
Text(food.name)
|
||||||
quantitySection
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
// Meal picker
|
if let brand = food.brand, !brand.isEmpty {
|
||||||
mealPickerSection
|
Text(brand)
|
||||||
|
.font(.subheadline)
|
||||||
// Macro preview
|
.foregroundStyle(Color.textSecondary)
|
||||||
macroPreview
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Add button
|
|
||||||
Button(action: onAdd) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if isAdding {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
.tint(.white)
|
|
||||||
}
|
}
|
||||||
Text("Add to \(mealType.displayName)")
|
Text("Per \(food.baseUnit)")
|
||||||
.font(.body.weight(.semibold))
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 16)
|
.padding()
|
||||||
.background(Color.accentWarm)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
.disabled(isAdding || quantity <= 0)
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
.background(Color.canvas)
|
|
||||||
.navigationTitle("Add Food")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
|
||||||
Button("Cancel") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
quantityText = formatQuantity(quantity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var foodHeader: some View {
|
// Quantity
|
||||||
HStack(spacing: 14) {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.accentWarmBg)
|
|
||||||
.frame(width: 48, height: 48)
|
|
||||||
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(food.name)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
.lineLimit(2)
|
|
||||||
|
|
||||||
Text("\(Int(food.caloriesPerBase)) kcal per \(food.displayUnit)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var quantitySection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Quantity (\(food.displayUnit))")
|
Text("Quantity")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textSecondary)
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack {
|
||||||
// Decrement
|
|
||||||
Button {
|
Button {
|
||||||
adjustQuantity(by: -0.5)
|
if quantity > 0.5 { quantity -= 0.5 }
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "minus.circle.fill")
|
Image(systemName: "minus.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(quantity > 0.5 ? Color.accentWarm : Color.text4)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
|
||||||
.disabled(quantity <= 0.5)
|
|
||||||
|
|
||||||
// Text field
|
|
||||||
TextField("1", text: $quantityText)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.keyboardType(.decimalPad)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.font(.title2.weight(.bold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
.frame(width: 80)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.onChange(of: quantityText) {
|
|
||||||
if let val = Double(quantityText), val > 0 {
|
|
||||||
quantity = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment
|
Text(String(format: "%.1f", quantity))
|
||||||
|
.font(.title2.weight(.bold).monospacedDigit())
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
.frame(minWidth: 60)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
adjustQuantity(by: 0.5)
|
quantity += 0.5
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Spacer()
|
// Serving picker
|
||||||
|
if let servings = food.servings, !servings.isEmpty {
|
||||||
|
Picker("Serving", selection: $selectedServingId) {
|
||||||
|
Text("Base (\(food.baseUnit))").tag(nil as String?)
|
||||||
|
ForEach(servings) { serving in
|
||||||
|
Text(serving.name).tag(serving.id as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
// Quick presets
|
// Meal picker
|
||||||
ForEach([0.5, 1.0, 2.0], id: \.self) { preset in
|
|
||||||
Button {
|
|
||||||
quantity = preset
|
|
||||||
quantityText = formatQuantity(preset)
|
|
||||||
} label: {
|
|
||||||
Text(formatQuantity(preset))
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(quantity == preset ? .white : Color.text2)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(quantity == preset ? Color.accentWarm : Color.surfaceSecondary)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mealPickerSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Meal")
|
Text("Meal")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textSecondary)
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(MealType.allCases) { meal in
|
ForEach(MealType.allCases, id: \.rawValue) { meal in
|
||||||
Button {
|
Button {
|
||||||
mealType = meal
|
selectedMeal = meal
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Image(systemName: meal.icon)
|
Image(systemName: meal.icon)
|
||||||
@@ -177,63 +111,133 @@ struct AddFoodSheet: View {
|
|||||||
Text(meal.displayName)
|
Text(meal.displayName)
|
||||||
.font(.caption2.weight(.medium))
|
.font(.caption2.weight(.medium))
|
||||||
}
|
}
|
||||||
.foregroundStyle(mealType == meal ? .white : Color.text2)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(selectedMeal == meal
|
||||||
mealType == meal
|
? Color.mealColor(for: meal.rawValue).opacity(0.15)
|
||||||
|
: Color.surfaceCard
|
||||||
|
)
|
||||||
|
.foregroundStyle(selectedMeal == meal
|
||||||
? Color.mealColor(for: meal.rawValue)
|
? Color.mealColor(for: meal.rawValue)
|
||||||
: Color.surfaceSecondary
|
: Color.textSecondary
|
||||||
)
|
)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(selectedMeal == meal
|
||||||
|
? Color.mealColor(for: meal.rawValue).opacity(0.3)
|
||||||
|
: Color.clear, lineWidth: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding()
|
||||||
|
.background(Color.surfaceCard)
|
||||||
private var macroPreview: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text("Nutrition Preview")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
macroPreviewItem("Calories", value: food.scaledCalories(quantity: quantity), unit: "kcal", color: .caloriesColor)
|
|
||||||
Spacer()
|
|
||||||
macroPreviewItem("Protein", value: food.scaledProtein(quantity: quantity), unit: "g", color: .proteinColor)
|
|
||||||
Spacer()
|
|
||||||
macroPreviewItem("Carbs", value: food.scaledCarbs(quantity: quantity), unit: "g", color: .carbsColor)
|
|
||||||
Spacer()
|
|
||||||
macroPreviewItem("Fat", value: food.scaledFat(quantity: quantity), unit: "g", color: .fatColor)
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
// Nutrition preview
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Nutrition Preview")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
], spacing: 12) {
|
||||||
|
nutritionCell("Calories", value: previewCalories, unit: "kcal", color: .emerald)
|
||||||
|
nutritionCell("Protein", value: previewProtein, unit: "g", color: .macroProtein)
|
||||||
|
nutritionCell("Carbs", value: previewCarbs, unit: "g", color: .macroCarbs)
|
||||||
|
nutritionCell("Fat", value: previewFat, unit: "g", color: .macroFat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add button
|
||||||
|
Button {
|
||||||
|
addEntry()
|
||||||
|
} label: {
|
||||||
|
if isAdding {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
} else {
|
||||||
|
Text("Add to \(selectedMeal.displayName)")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(Color.accentWarm)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.disabled(isAdding)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationTitle("Add Food")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let ds = defaultServing {
|
||||||
|
selectedServingId = ds.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func macroPreviewItem(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("\(Int(value))")
|
Text("\(Int(value))")
|
||||||
.font(.system(.title3, design: .rounded, weight: .bold))
|
.font(.headline.monospacedDigit())
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
Text(label)
|
Text("\(unit)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func adjustQuantity(by amount: Double) {
|
private func addEntry() {
|
||||||
quantity = max(0.5, quantity + amount)
|
isAdding = true
|
||||||
quantityText = formatQuantity(quantity)
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let request = CreateEntryRequest(
|
||||||
|
foodId: food.id,
|
||||||
|
quantity: quantity,
|
||||||
|
unit: food.baseUnit,
|
||||||
|
mealType: selectedMeal.rawValue,
|
||||||
|
entryDate: entryDate.apiDateString,
|
||||||
|
entryMethod: "manual",
|
||||||
|
source: "ios",
|
||||||
|
servingId: selectedServingId
|
||||||
|
)
|
||||||
|
_ = try await FitnessRepository.shared.createEntry(request)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isAdding = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatQuantity(_ qty: Double) -> String {
|
|
||||||
if qty == qty.rounded() {
|
|
||||||
return "\(Int(qty))"
|
|
||||||
}
|
|
||||||
return String(format: "%.1f", qty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,256 +3,121 @@ import SwiftUI
|
|||||||
struct EntryDetailView: View {
|
struct EntryDetailView: View {
|
||||||
let entry: FoodEntry
|
let entry: FoodEntry
|
||||||
let onDelete: () -> Void
|
let onDelete: () -> Void
|
||||||
let onUpdateQuantity: (Double) -> Void
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var editQuantity: String
|
@State private var showDeleteConfirmation = false
|
||||||
@State private var showDeleteConfirm = false
|
|
||||||
|
|
||||||
init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) {
|
|
||||||
self.entry = entry
|
|
||||||
self.onDelete = onDelete
|
|
||||||
self.onUpdateQuantity = onUpdateQuantity
|
|
||||||
_editQuantity = State(initialValue: entry.quantity == entry.quantity.rounded() ? "\(Int(entry.quantity))" : String(format: "%.1f", entry.quantity))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
// Header
|
||||||
entryHeader
|
VStack(spacing: 6) {
|
||||||
|
Text(entry.foodName)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
// Quantity editor
|
if let desc = entry.servingDescription {
|
||||||
quantityEditor
|
Text(desc)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
// Macros grid
|
HStack(spacing: 8) {
|
||||||
macrosGrid
|
Image(systemName: (MealType(rawValue: entry.mealType) ?? .snack).icon)
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
Text(entry.mealType.capitalized)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
|
||||||
// Details
|
// Nutrition grid
|
||||||
detailsSection
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
], spacing: 16) {
|
||||||
|
nutritionCell("Calories", value: entry.calories, unit: "kcal", color: .emerald)
|
||||||
|
nutritionCell("Protein", value: entry.protein, unit: "g", color: .macroProtein)
|
||||||
|
nutritionCell("Carbs", value: entry.carbs, unit: "g", color: .macroCarbs)
|
||||||
|
nutritionCell("Fat", value: entry.fat, unit: "g", color: .macroFat)
|
||||||
|
nutritionCell("Sugar", value: entry.sugar, unit: "g", color: .orange)
|
||||||
|
nutritionCell("Fiber", value: entry.fiber, unit: "g", color: .green)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
metadataRow("Date", value: entry.entryDate)
|
||||||
|
metadataRow("Quantity", value: "\(String(format: "%.1f", entry.quantity)) \(entry.unit)")
|
||||||
|
metadataRow("Source", value: entry.source)
|
||||||
|
metadataRow("Method", value: entry.entryMethod)
|
||||||
|
if let note = entry.note, !note.isEmpty {
|
||||||
|
metadataRow("Note", value: note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
// Delete button
|
// Delete button
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showDeleteConfirm = true
|
showDeleteConfirmation = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
Label("Delete Entry", systemImage: "trash")
|
||||||
Image(systemName: "trash")
|
.font(.headline)
|
||||||
Text("Delete Entry")
|
|
||||||
}
|
|
||||||
.font(.body.weight(.medium))
|
|
||||||
.foregroundStyle(Color.error)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 14)
|
.frame(height: 48)
|
||||||
.background(Color.error.opacity(0.06))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding()
|
||||||
}
|
}
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.navigationTitle("Entry Details")
|
.navigationTitle("Entry Detail")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.alert("Delete Entry?", isPresented: $showDeleteConfirmation) {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.confirmationDialog("Delete Entry", isPresented: $showDeleteConfirm) {
|
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
onDelete()
|
onDelete()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to delete \"\(entry.foodName)\"?")
|
Text("This will permanently remove \(entry.foodName) from your log.")
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var entryHeader: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
|
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
|
|
||||||
Image(systemName: Color.mealIcon(for: entry.mealType))
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(entry.foodName)
|
|
||||||
.font(.title3.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Text(entry.mealType.capitalized)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.mealColor(for: entry.mealType).opacity(0.1))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var quantityEditor: some View {
|
private func nutritionCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Quantity")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
let current = Double(editQuantity) ?? 1
|
|
||||||
let newVal = max(0.5, current - 0.5)
|
|
||||||
editQuantity = formatQuantity(newVal)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "minus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField("1", text: $editQuantity)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.keyboardType(.decimalPad)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.font(.title2.weight(.bold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
.frame(width: 80)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
|
|
||||||
Button {
|
|
||||||
let current = Double(editQuantity) ?? 1
|
|
||||||
editQuantity = formatQuantity(current + 0.5)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button("Save") {
|
|
||||||
if let qty = Double(editQuantity), qty > 0 {
|
|
||||||
onUpdateQuantity(qty)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.accentWarm)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var macrosGrid: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Nutrition")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
LazyVGrid(columns: [
|
|
||||||
GridItem(.flexible()),
|
|
||||||
GridItem(.flexible()),
|
|
||||||
GridItem(.flexible())
|
|
||||||
], spacing: 12) {
|
|
||||||
macroCell("Calories", value: entry.calories, unit: "kcal", color: .caloriesColor)
|
|
||||||
macroCell("Protein", value: entry.protein, unit: "g", color: .proteinColor)
|
|
||||||
macroCell("Carbs", value: entry.carbs, unit: "g", color: .carbsColor)
|
|
||||||
macroCell("Fat", value: entry.fat, unit: "g", color: .fatColor)
|
|
||||||
if let sugar = entry.sugar {
|
|
||||||
macroCell("Sugar", value: sugar, unit: "g", color: .sugarColor)
|
|
||||||
}
|
|
||||||
if let fiber = entry.fiber {
|
|
||||||
macroCell("Fiber", value: fiber, unit: "g", color: .fiberColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func macroCell(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("\(Int(value))")
|
Text("\(Int(value))")
|
||||||
.font(.title3.weight(.bold))
|
.font(.title3.weight(.bold).monospacedDigit())
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
Text(label)
|
Text(unit)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(color.opacity(0.06))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var detailsSection: some View {
|
private func metadataRow(_ label: String, value: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Details")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
detailRow("Serving", value: entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)")
|
|
||||||
|
|
||||||
if let method = entry.method, !method.isEmpty {
|
|
||||||
Divider()
|
|
||||||
detailRow("Method", value: method)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let note = entry.note, !note.isEmpty {
|
|
||||||
Divider()
|
|
||||||
detailRow("Note", value: note)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let loggedAt = entry.loggedAt, !loggedAt.isEmpty {
|
|
||||||
Divider()
|
|
||||||
detailRow("Logged", value: loggedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func detailRow(_ label: String, value: String) -> some View {
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textSecondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.text1)
|
.foregroundStyle(Color.textPrimary)
|
||||||
.lineLimit(2)
|
}
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatQuantity(_ qty: Double) -> String {
|
|
||||||
if qty == qty.rounded() {
|
|
||||||
return "\(Int(qty))"
|
|
||||||
}
|
|
||||||
return String(format: "%.1f", qty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,55 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FitnessTabView: View {
|
struct FitnessTabView: View {
|
||||||
@State private var selectedTab: FitnessTab = .today
|
@State private var selectedSubTab = 0
|
||||||
@State private var todayVM = TodayViewModel()
|
|
||||||
@State private var showFoodSearch = false
|
|
||||||
|
|
||||||
enum FitnessTab: String, CaseIterable {
|
|
||||||
case today = "Today"
|
|
||||||
case templates = "Templates"
|
|
||||||
case goals = "Goals"
|
|
||||||
case foods = "Foods"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Custom segmented control
|
// Sub-tab selector
|
||||||
tabBar
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(fitnessSubTabs.enumerated()), id: \.offset) { index, tab in
|
||||||
// Content
|
|
||||||
Group {
|
|
||||||
switch selectedTab {
|
|
||||||
case .today:
|
|
||||||
TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch)
|
|
||||||
case .templates:
|
|
||||||
TemplatesView(dateString: todayVM.dateString) {
|
|
||||||
Task { await todayVM.load() }
|
|
||||||
}
|
|
||||||
case .goals:
|
|
||||||
GoalsView()
|
|
||||||
case .foods:
|
|
||||||
FoodLibraryView(dateString: todayVM.dateString) {
|
|
||||||
Task { await todayVM.load() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.canvas)
|
|
||||||
.navigationTitle("Fitness")
|
|
||||||
.navigationBarTitleDisplayMode(.large)
|
|
||||||
.sheet(isPresented: $showFoodSearch) {
|
|
||||||
FoodSearchView(date: todayVM.dateString) {
|
|
||||||
Task { await todayVM.load() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tabBar: some View {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ForEach(FitnessTab.allCases, id: \.rawValue) { tab in
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
selectedTab = tab
|
selectedSubTab = index
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(tab.rawValue)
|
Text(tab)
|
||||||
.font(.subheadline.weight(selectedTab == tab ? .semibold : .medium))
|
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
|
||||||
.foregroundStyle(selectedTab == tab ? Color.surface : Color.text3)
|
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
|
||||||
|
.padding(.vertical, 10)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.background {
|
||||||
.background(
|
if selectedSubTab == index {
|
||||||
selectedTab == tab
|
Capsule()
|
||||||
? Color.accentWarm
|
.fill(Color.accentWarm.opacity(0.12))
|
||||||
: Color.surfaceSecondary
|
|
||||||
)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
// Content
|
||||||
|
TabView(selection: $selectedSubTab) {
|
||||||
|
TodayView()
|
||||||
|
.tag(0)
|
||||||
|
TemplatesView()
|
||||||
|
.tag(1)
|
||||||
|
GoalsView()
|
||||||
|
.tag(2)
|
||||||
|
FoodLibraryView()
|
||||||
|
.tag(3)
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fitnessSubTabs: [String] {
|
||||||
|
["Today", "Templates", "Goals", "Foods"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,96 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// FoodLibraryView is not currently used in the app navigation.
|
|
||||||
// Placeholder kept for future use.
|
|
||||||
|
|
||||||
struct FoodLibraryView: View {
|
struct FoodLibraryView: View {
|
||||||
let dateString: String
|
@State private var vm = FoodSearchViewModel()
|
||||||
|
@State private var selectedFood: Food?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("Food Library")
|
VStack(spacing: 0) {
|
||||||
.font(.headline)
|
// Search bar
|
||||||
.foregroundStyle(Color.text3)
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
TextField("Search foods...", text: $vm.searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
if !vm.searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
vm.searchText = ""
|
||||||
|
vm.searchResults = []
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
let displayFoods = vm.searchText.isEmpty ? vm.allFoods : vm.searchResults
|
||||||
|
|
||||||
|
if vm.isLoadingInitial {
|
||||||
|
LoadingView()
|
||||||
|
} else if displayFoods.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "tray",
|
||||||
|
title: "No foods found",
|
||||||
|
subtitle: vm.searchText.isEmpty
|
||||||
|
? "Foods you add will appear here"
|
||||||
|
: "Try a different search term"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ForEach(displayFoods) { food in
|
||||||
|
Button {
|
||||||
|
selectedFood = food
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(food.name)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let brand = food.brand, !brand.isEmpty {
|
||||||
|
Text(brand)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
Text("\(Int(food.caloriesPerBase)) kcal")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
Text("\(Int(food.proteinPerBase))p \(Int(food.carbsPerBase))c \(Int(food.fatPerBase))f")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
Divider().padding(.leading, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.task {
|
||||||
|
await vm.loadInitial()
|
||||||
|
}
|
||||||
|
.onChange(of: vm.searchText) {
|
||||||
|
vm.search()
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedFood) { food in
|
||||||
|
AddFoodSheet(food: food)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +1,93 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FoodSearchView: View {
|
struct FoodSearchView: View {
|
||||||
let date: String
|
var isSheet: Bool = false
|
||||||
let onFoodAdded: () -> Void
|
@State private var vm = FoodSearchViewModel()
|
||||||
|
@State private var selectedFood: Food?
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@State private var viewModel = FoodSearchViewModel()
|
|
||||||
@FocusState private var searchFocused: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Search bar
|
// Search bar
|
||||||
searchBar
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
TextField("Search foods...", text: $vm.searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
if !vm.searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
vm.searchText = ""
|
||||||
|
vm.searchResults = []
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Content
|
if vm.isLoadingInitial {
|
||||||
if viewModel.isSearching || viewModel.isLoadingRecent {
|
LoadingView()
|
||||||
LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...")
|
} else if !vm.searchText.isEmpty {
|
||||||
} else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent {
|
// Search results
|
||||||
|
searchResultsList
|
||||||
|
} else {
|
||||||
|
// Recent + All
|
||||||
|
defaultList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.task {
|
||||||
|
await vm.loadInitial()
|
||||||
|
}
|
||||||
|
.onChange(of: vm.searchText) {
|
||||||
|
vm.search()
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedFood) { food in
|
||||||
|
AddFoodSheet(food: food)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchResultsList: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
if vm.isSearching {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
} else if vm.searchResults.isEmpty {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "magnifyingglass",
|
icon: "magnifyingglass",
|
||||||
title: "No results",
|
title: "No results",
|
||||||
subtitle: "Try a different search term"
|
subtitle: "Try a different search term"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
foodList
|
ForEach(vm.searchResults) { food in
|
||||||
|
foodRow(food)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.canvas)
|
|
||||||
.navigationTitle("Add Food")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
|
||||||
Button("Cancel") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $viewModel.showAddSheet) {
|
|
||||||
if let food = viewModel.selectedFood {
|
|
||||||
AddFoodSheet(
|
|
||||||
food: food,
|
|
||||||
quantity: $viewModel.addQuantity,
|
|
||||||
mealType: $viewModel.addMealType,
|
|
||||||
isAdding: viewModel.isAddingFood
|
|
||||||
) {
|
|
||||||
Task {
|
|
||||||
await viewModel.addFood(date: date) {
|
|
||||||
onFoodAdded()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.presentationDetents([.medium])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await viewModel.loadRecent()
|
|
||||||
searchFocused = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var searchBar: some View {
|
private var defaultList: some View {
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: "magnifyingglass")
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
|
|
||||||
TextField("Search foods...", text: $viewModel.searchText)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.focused($searchFocused)
|
|
||||||
.onSubmit {
|
|
||||||
viewModel.search()
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.searchText) {
|
|
||||||
viewModel.search()
|
|
||||||
}
|
|
||||||
|
|
||||||
if !viewModel.searchText.isEmpty {
|
|
||||||
Button {
|
|
||||||
viewModel.searchText = ""
|
|
||||||
viewModel.searchResults = []
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var foodList: some View {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty {
|
if !vm.recentFoods.isEmpty {
|
||||||
sectionHeader("Recent Foods")
|
sectionHeader("Recent")
|
||||||
|
ForEach(vm.recentFoods) { recent in
|
||||||
|
recentFoodRow(recent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.displayedFoods) { food in
|
if !vm.allFoods.isEmpty {
|
||||||
FoodItemRow(food: food) {
|
sectionHeader("All Foods")
|
||||||
viewModel.selectFood(food)
|
ForEach(vm.allFoods) { food in
|
||||||
|
foodRow(food)
|
||||||
}
|
}
|
||||||
Divider()
|
|
||||||
.padding(.leading, 60)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,81 +96,65 @@ struct FoodSearchView: View {
|
|||||||
private func sectionHeader(_ title: String) -> some View {
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(Color.text4)
|
.foregroundStyle(Color.textTertiary)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.padding(.horizontal, 20)
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 4)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FoodItemRow: View {
|
private func foodRow(_ food: Food) -> some View {
|
||||||
let food: FoodItem
|
Button {
|
||||||
let onTap: () -> Void
|
selectedFood = food
|
||||||
|
} label: {
|
||||||
var body: some View {
|
HStack {
|
||||||
Button(action: onTap) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Icon
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(Color.accentWarmBg)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
|
|
||||||
if let imageUrl = food.imageUrl, !imageUrl.isEmpty {
|
|
||||||
AsyncImage(url: URL(string: imageUrl)) { image in
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
} placeholder: {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(food.name)
|
Text(food.name)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.text1)
|
.foregroundStyle(Color.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
if let brand = food.brand, !brand.isEmpty {
|
||||||
Text(food.displayInfo)
|
Text(brand)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(food.caloriesPerBase)) kcal/\(food.baseUnit)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recentFoodRow(_ recent: RecentFood) -> some View {
|
||||||
|
Button {
|
||||||
|
// Navigate to add sheet by loading the full food
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let food = try await FitnessAPI().getFood(id: recent.foodId)
|
||||||
|
selectedFood = food
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(recent.name)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Text("\(Int(recent.caloriesPerBase)) kcal")
|
||||||
// Calories
|
.font(.caption.weight(.medium))
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
.foregroundStyle(Color.textSecondary)
|
||||||
Text("\(Int(food.caloriesPerBase))")
|
|
||||||
.font(.subheadline.weight(.bold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
|
|
||||||
Text("kcal")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption2.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.contentShape(Rectangle())
|
}
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,139 +1,84 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GoalsView: View {
|
struct GoalsView: View {
|
||||||
@State private var viewModel = GoalsViewModel()
|
@State private var vm = GoalsViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
if viewModel.isLoading {
|
VStack(spacing: 16) {
|
||||||
LoadingView(message: "Loading goals...")
|
if vm.isLoading {
|
||||||
.frame(height: 300)
|
LoadingView()
|
||||||
|
} else if let goal = vm.goal {
|
||||||
|
goalCard(goal)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 20) {
|
EmptyStateView(
|
||||||
// Header
|
icon: "target",
|
||||||
Text("Your daily targets")
|
title: "No active goal",
|
||||||
.font(.headline)
|
subtitle: vm.error ?? "Set goals from the web app"
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
// Goals cards
|
|
||||||
goalCard(
|
|
||||||
label: "Calories",
|
|
||||||
value: viewModel.goal.calories,
|
|
||||||
unit: "kcal",
|
|
||||||
icon: "flame.fill",
|
|
||||||
color: .caloriesColor
|
|
||||||
)
|
|
||||||
|
|
||||||
goalCard(
|
|
||||||
label: "Protein",
|
|
||||||
value: viewModel.goal.protein,
|
|
||||||
unit: "g",
|
|
||||||
icon: "circle.hexagonpath.fill",
|
|
||||||
color: .proteinColor
|
|
||||||
)
|
|
||||||
|
|
||||||
goalCard(
|
|
||||||
label: "Carbs",
|
|
||||||
value: viewModel.goal.carbs,
|
|
||||||
unit: "g",
|
|
||||||
icon: "bolt.fill",
|
|
||||||
color: .carbsColor
|
|
||||||
)
|
|
||||||
|
|
||||||
goalCard(
|
|
||||||
label: "Fat",
|
|
||||||
value: viewModel.goal.fat,
|
|
||||||
unit: "g",
|
|
||||||
icon: "drop.fill",
|
|
||||||
color: .fatColor
|
|
||||||
)
|
|
||||||
|
|
||||||
if let sugar = viewModel.goal.sugar, sugar > 0 {
|
|
||||||
goalCard(
|
|
||||||
label: "Sugar",
|
|
||||||
value: sugar,
|
|
||||||
unit: "g",
|
|
||||||
icon: "cube.fill",
|
|
||||||
color: .sugarColor
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let fiber = viewModel.goal.fiber, fiber > 0 {
|
Spacer(minLength: 80)
|
||||||
goalCard(
|
|
||||||
label: "Fiber",
|
|
||||||
value: fiber,
|
|
||||||
unit: "g",
|
|
||||||
icon: "leaf.fill",
|
|
||||||
color: .fiberColor
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
// Info note
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
Text("Goals can be updated from the web app")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.background(Color.canvas)
|
||||||
}
|
.task {
|
||||||
|
await vm.load()
|
||||||
if let error = viewModel.errorMessage {
|
|
||||||
ErrorBanner(message: error) {
|
|
||||||
Task { await viewModel.load() }
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.load()
|
await vm.load()
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await viewModel.load()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func goalCard(label: String, value: Double, unit: String, icon: String, color: Color) -> some View {
|
private func goalCard(_ goal: DailyGoal) -> some View {
|
||||||
HStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ZStack {
|
Text("Daily Goals")
|
||||||
RoundedRectangle(cornerRadius: 12)
|
.font(.headline)
|
||||||
.fill(color.opacity(0.1))
|
.foregroundStyle(Color.textPrimary)
|
||||||
.frame(width: 48, height: 48)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
Image(systemName: icon)
|
LazyVGrid(columns: [
|
||||||
.font(.title3)
|
GridItem(.flexible()),
|
||||||
.foregroundStyle(color)
|
GridItem(.flexible()),
|
||||||
|
], spacing: 16) {
|
||||||
|
goalItem("Calories", value: goal.calories, unit: "kcal", color: .emerald)
|
||||||
|
goalItem("Protein", value: goal.protein, unit: "g", color: .macroProtein)
|
||||||
|
goalItem("Carbs", value: goal.carbs, unit: "g", color: .macroCarbs)
|
||||||
|
goalItem("Fat", value: goal.fat, unit: "g", color: .macroFat)
|
||||||
|
goalItem("Sugar", value: goal.sugar, unit: "g", color: .orange)
|
||||||
|
goalItem("Fiber", value: goal.fiber, unit: "g", color: .green)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack {
|
||||||
Text(label)
|
Text("Active since \(goal.startDate)")
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.foregroundStyle(Color.text2)
|
|
||||||
|
|
||||||
Text("Daily target")
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.text4)
|
.foregroundStyle(Color.textTertiary)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
|
||||||
Text("\(Int(value))")
|
|
||||||
.font(.title2.weight(.bold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
Text(unit)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding()
|
||||||
.background(Color.surface)
|
.background(Color.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goalItem(_ label: String, value: Double, unit: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("\(Int(value))")
|
||||||
|
.font(.title2.weight(.bold).monospacedDigit())
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text("\(unit)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(color.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +1,112 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MealSectionView: View {
|
struct MealSectionView: View {
|
||||||
let group: MealGroup
|
let mealType: MealType
|
||||||
let isExpanded: Bool
|
let entries: [FoodEntry]
|
||||||
let onToggle: () -> Void
|
|
||||||
let onDelete: (FoodEntry) -> Void
|
let onDelete: (FoodEntry) -> Void
|
||||||
let onAddFood: () -> Void
|
|
||||||
|
|
||||||
@State private var selectedEntry: FoodEntry?
|
@State private var isExpanded = true
|
||||||
|
|
||||||
private var mealColor: Color {
|
private var totalCalories: Double {
|
||||||
Color.mealColor(for: group.meal.rawValue)
|
entries.reduce(0) { $0 + $1.calories }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Header
|
||||||
Button(action: onToggle) {
|
Button {
|
||||||
HStack(spacing: 10) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
Image(systemName: group.meal.icon)
|
isExpanded.toggle()
|
||||||
.font(.body)
|
}
|
||||||
.foregroundStyle(mealColor)
|
} label: {
|
||||||
.frame(width: 28)
|
HStack(spacing: 12) {
|
||||||
|
// Accent bar
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.mealColor(for: mealType.rawValue))
|
||||||
|
.frame(width: 4, height: 32)
|
||||||
|
|
||||||
Text(group.meal.displayName)
|
Image(systemName: mealType.icon)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.body.weight(.medium))
|
||||||
.foregroundStyle(Color.text1)
|
.foregroundStyle(Color.mealColor(for: mealType.rawValue))
|
||||||
|
|
||||||
if !group.entries.isEmpty {
|
Text(mealType.displayName)
|
||||||
Text("\(group.entries.count)")
|
.font(.headline)
|
||||||
.font(.caption2.weight(.bold))
|
.foregroundStyle(Color.textPrimary)
|
||||||
.foregroundStyle(mealColor)
|
|
||||||
|
Text("\(entries.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(mealColor.opacity(0.1))
|
.background(Color.textSecondary.opacity(0.1))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if !group.entries.isEmpty {
|
Text("\(Int(totalCalories)) kcal")
|
||||||
Text("\(Int(group.totalCalories)) kcal")
|
.font(.subheadline.weight(.semibold))
|
||||||
.font(.caption.weight(.semibold))
|
.foregroundStyle(Color.textSecondary)
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.medium))
|
||||||
.foregroundStyle(Color.text4)
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
// Entries
|
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
if group.entries.isEmpty {
|
ForEach(entries) { entry in
|
||||||
emptyMealView
|
NavigationLink(destination: EntryDetailView(entry: entry, onDelete: {
|
||||||
} else {
|
onDelete(entry)
|
||||||
Divider()
|
})) {
|
||||||
.padding(.horizontal, 16)
|
entryRow(entry)
|
||||||
|
|
||||||
ForEach(group.entries) { entry in
|
|
||||||
SwipeToDeleteRow(onDelete: { onDelete(entry) }) {
|
|
||||||
EntryRow(entry: entry)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedEntry = entry
|
|
||||||
}
|
}
|
||||||
}
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
if entry.id != group.entries.last?.id {
|
onDelete(entry)
|
||||||
Divider()
|
} label: {
|
||||||
.padding(.leading, 52)
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.surface)
|
.background(Color.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
|
||||||
.sheet(item: $selectedEntry) { entry in
|
.padding(.horizontal)
|
||||||
EntryDetailView(
|
|
||||||
entry: entry,
|
|
||||||
onDelete: { onDelete(entry) },
|
|
||||||
onUpdateQuantity: { _ in }
|
|
||||||
)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyMealView: some View {
|
private func entryRow(_ entry: FoodEntry) -> some View {
|
||||||
Button(action: onAddFood) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "plus.circle")
|
|
||||||
.foregroundStyle(mealColor)
|
|
||||||
Text("Add food")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Swipe to Delete Row
|
|
||||||
|
|
||||||
struct SwipeToDeleteRow<Content: View>: View {
|
|
||||||
let onDelete: () -> Void
|
|
||||||
@ViewBuilder let content: () -> Content
|
|
||||||
|
|
||||||
@State private var offset: CGFloat = 0
|
|
||||||
@State private var showDelete = false
|
|
||||||
|
|
||||||
private let deleteThreshold: CGFloat = -80
|
|
||||||
private let deleteWidth: CGFloat = 80
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .trailing) {
|
|
||||||
// Delete background
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Button(action: {
|
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
|
||||||
offset = -300
|
|
||||||
}
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
||||||
onDelete()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "trash.fill")
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: deleteWidth, height: .infinity)
|
|
||||||
}
|
|
||||||
.frame(width: deleteWidth)
|
|
||||||
.background(Color.error)
|
|
||||||
}
|
|
||||||
.opacity(offset < 0 ? 1 : 0)
|
|
||||||
|
|
||||||
// Content
|
|
||||||
content()
|
|
||||||
.offset(x: offset)
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 20)
|
|
||||||
.onChanged { value in
|
|
||||||
let translation = value.translation.width
|
|
||||||
if translation < 0 {
|
|
||||||
offset = translation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
|
||||||
if offset < deleteThreshold {
|
|
||||||
offset = -deleteWidth
|
|
||||||
showDelete = true
|
|
||||||
} else {
|
|
||||||
offset = 0
|
|
||||||
showDelete = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.onTapGesture {
|
|
||||||
if showDelete {
|
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
|
||||||
offset = 0
|
|
||||||
showDelete = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clipped()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Entry Row
|
|
||||||
|
|
||||||
struct EntryRow: View {
|
|
||||||
let entry: FoodEntry
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Food icon or image
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.mealColor(for: entry.mealType).opacity(0.1))
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
|
|
||||||
if let imageUrl = entry.imageUrl, !imageUrl.isEmpty {
|
|
||||||
AsyncImage(url: URL(string: imageUrl)) { image in
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
.clipShape(Circle())
|
|
||||||
} placeholder: {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.mealColor(for: entry.mealType))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name and serving
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(entry.foodName)
|
Text(entry.foodName)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.text1)
|
.foregroundStyle(Color.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unitLabel)")
|
if let desc = entry.servingDescription ?? entry.snapshotServingLabel {
|
||||||
|
Text(desc)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textTertiary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Macros
|
Text("\(Int(entry.calories)) kcal")
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
.font(.subheadline.weight(.medium))
|
||||||
Text("\(Int(entry.calories))")
|
.foregroundStyle(Color.textSecondary)
|
||||||
.font(.subheadline.weight(.bold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
+ Text(" kcal")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
Image(systemName: "chevron.right")
|
||||||
macroTag("P", value: entry.protein, color: .proteinColor)
|
.font(.caption2)
|
||||||
macroTag("C", value: entry.carbs, color: .carbsColor)
|
.foregroundStyle(Color.textTertiary)
|
||||||
macroTag("F", value: entry.fat, color: .fatColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(Color.surface)
|
.background(Color.surfaceCard)
|
||||||
}
|
|
||||||
|
|
||||||
private func macroTag(_ label: String, value: Double, color: Color) -> some View {
|
|
||||||
Text("\(label)\(Int(value))")
|
|
||||||
.font(.system(size: 10, weight: .semibold))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatQuantity(_ qty: Double) -> String {
|
|
||||||
if qty == qty.rounded() {
|
|
||||||
return "\(Int(qty))"
|
|
||||||
}
|
|
||||||
return String(format: "%.1f", qty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,170 +1,125 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TemplatesView: View {
|
struct TemplatesView: View {
|
||||||
let dateString: String
|
@State private var vm = TemplatesViewModel()
|
||||||
let onTemplateLogged: () -> Void
|
|
||||||
|
|
||||||
@State private var viewModel = TemplatesViewModel()
|
|
||||||
@State private var confirmTemplate: MealTemplate?
|
@State private var confirmTemplate: MealTemplate?
|
||||||
|
@State private var logMealType: MealType = .snack
|
||||||
|
@State private var showConfirmation = false
|
||||||
|
@State private var logResult: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
if viewModel.isLoading {
|
VStack(spacing: 16) {
|
||||||
LoadingView(message: "Loading templates...")
|
if vm.isLoading {
|
||||||
.frame(height: 300)
|
LoadingView()
|
||||||
} else if viewModel.templates.isEmpty {
|
} else if vm.templates.isEmpty {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "doc.text",
|
icon: "doc.text",
|
||||||
title: "No templates",
|
title: "No templates",
|
||||||
subtitle: "Create templates on the web app to quickly log meals"
|
subtitle: "Create meal templates from the web app"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyVStack(spacing: 16) {
|
ForEach(vm.groupedByMealType, id: \.0) { group, templates in
|
||||||
ForEach(MealType.allCases) { meal in
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
let templates = viewModel.groupedTemplates[meal.rawValue] ?? []
|
Text(group)
|
||||||
if !templates.isEmpty {
|
.font(.caption.weight(.semibold))
|
||||||
templateSection(meal: meal, templates: templates)
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
|
ForEach(templates) { template in
|
||||||
|
templateCard(template)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ungrouped
|
Spacer(minLength: 80)
|
||||||
let ungrouped = viewModel.templates.filter { template in
|
|
||||||
!MealType.allCases.contains(template.mealType)
|
|
||||||
}
|
}
|
||||||
if !ungrouped.isEmpty {
|
.padding(.horizontal)
|
||||||
templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped)
|
.padding(.top, 8)
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = viewModel.errorMessage {
|
|
||||||
ErrorBanner(message: error) {
|
|
||||||
Task { await viewModel.load() }
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
}
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
|
.task {
|
||||||
|
await vm.load()
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.load()
|
await vm.load()
|
||||||
}
|
}
|
||||||
.task {
|
.alert("Log Template", isPresented: $showConfirmation) {
|
||||||
await viewModel.load()
|
Button("Log") {
|
||||||
}
|
guard let template = confirmTemplate else { return }
|
||||||
.confirmationDialog(
|
|
||||||
"Log Template",
|
|
||||||
isPresented: Binding(
|
|
||||||
get: { confirmTemplate != nil },
|
|
||||||
set: { if !$0 { confirmTemplate = nil } }
|
|
||||||
),
|
|
||||||
presenting: confirmTemplate
|
|
||||||
) { template in
|
|
||||||
Button("Log \"\(template.name)\"") {
|
|
||||||
Task {
|
Task {
|
||||||
await viewModel.logTemplate(template, date: dateString) {
|
do {
|
||||||
onTemplateLogged()
|
let result = try await vm.logTemplate(
|
||||||
|
template,
|
||||||
|
mealType: logMealType.rawValue,
|
||||||
|
entryDate: Date().apiDateString
|
||||||
|
)
|
||||||
|
logResult = "Logged \(result.logged) items"
|
||||||
|
} catch {
|
||||||
|
logResult = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: { template in
|
} message: {
|
||||||
Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).")
|
if let template = confirmTemplate {
|
||||||
|
Text("Add \(template.items.count) items from \"\(template.name)\" to \(logMealType.displayName)?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Result", isPresented: .init(
|
||||||
|
get: { logResult != nil },
|
||||||
|
set: { if !$0 { logResult = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { logResult = nil }
|
||||||
|
} message: {
|
||||||
|
Text(logResult ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func templateSection(meal: MealType, templates: [MealTemplate]) -> some View {
|
private func templateCard(_ template: MealTemplate) -> some View {
|
||||||
templateSection(
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
mealLabel: meal.displayName,
|
HStack {
|
||||||
icon: meal.icon,
|
|
||||||
color: Color.mealColor(for: meal.rawValue),
|
|
||||||
templates: templates
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func templateSection(mealLabel: String, icon: String, color: Color, templates: [MealTemplate]) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundStyle(color)
|
|
||||||
Text(mealLabel)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(templates) { template in
|
|
||||||
TemplateCard(
|
|
||||||
template: template,
|
|
||||||
isLogging: viewModel.loggedTemplateId == template.id
|
|
||||||
) {
|
|
||||||
confirmTemplate = template
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TemplateCard: View {
|
|
||||||
let template: MealTemplate
|
|
||||||
let isLogging: Bool
|
|
||||||
let onLog: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
// Icon
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(Color.mealColor(for: template.mealType).opacity(0.1))
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
|
|
||||||
Image(systemName: "doc.text.fill")
|
|
||||||
.foregroundStyle(Color.mealColor(for: template.mealType))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(template.name)
|
Text(template.name)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.headline)
|
||||||
.foregroundStyle(Color.text1)
|
.foregroundStyle(Color.textPrimary)
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text("\(Int(template.calories)) kcal")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.caloriesColor)
|
|
||||||
|
|
||||||
if let count = template.itemsCount {
|
|
||||||
Text("\(count) items")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let protein = template.protein {
|
|
||||||
Text("P\(Int(protein))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.proteinColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Log button
|
let totalCals = template.items.reduce(0.0) { $0 + $1.calories }
|
||||||
Button(action: onLog) {
|
Text("\(Int(totalCals)) kcal")
|
||||||
if isLogging {
|
.font(.subheadline.weight(.medium))
|
||||||
ProgressView()
|
.foregroundStyle(Color.textSecondary)
|
||||||
.controlSize(.small)
|
}
|
||||||
.tint(Color.accentWarm)
|
|
||||||
} else {
|
ForEach(template.items) { item in
|
||||||
Image(systemName: "plus.circle.fill")
|
HStack {
|
||||||
.font(.title3)
|
Text(item.foodName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(item.calories)) kcal")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
confirmTemplate = template
|
||||||
|
logMealType = MealType(rawValue: template.mealType ?? "snack") ?? .snack
|
||||||
|
showConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label("Log", systemImage: "plus.circle.fill")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isLogging)
|
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding()
|
||||||
.background(Color.surface)
|
.background(Color.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
.shadow(color: .black.opacity(0.04), radius: 4, y: 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,124 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TodayView: View {
|
struct TodayView: View {
|
||||||
@Bindable var viewModel: TodayViewModel
|
@State private var vm = TodayViewModel()
|
||||||
@Binding var showFoodSearch: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Date selector
|
// Date selector
|
||||||
dateSelector
|
dateSelector
|
||||||
|
|
||||||
if viewModel.isLoading {
|
// Macro summary
|
||||||
LoadingView(message: "Loading entries...")
|
macroSummary
|
||||||
.frame(height: 200)
|
|
||||||
} else {
|
|
||||||
// Macro summary card
|
|
||||||
macroSummaryCard
|
|
||||||
|
|
||||||
// Meal sections
|
// Meal sections
|
||||||
ForEach(viewModel.mealGroups) { group in
|
if vm.entries.isEmpty && !vm.isLoading {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "fork.knife",
|
||||||
|
title: "No entries yet",
|
||||||
|
subtitle: "Tap + to log your first meal"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ForEach(vm.mealGroups, id: \.0) { mealType, entries in
|
||||||
MealSectionView(
|
MealSectionView(
|
||||||
group: group,
|
mealType: mealType,
|
||||||
isExpanded: viewModel.expandedMeals.contains(group.meal.rawValue),
|
entries: entries,
|
||||||
onToggle: { viewModel.toggleMeal(group.meal.rawValue) },
|
|
||||||
onDelete: { entry in
|
onDelete: { entry in
|
||||||
Task { await viewModel.deleteEntry(entry) }
|
Task { await vm.deleteEntry(entry) }
|
||||||
},
|
|
||||||
onAddFood: {
|
|
||||||
showFoodSearch = true
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom spacing for FAB
|
|
||||||
Spacer()
|
|
||||||
.frame(height: 80)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = viewModel.errorMessage {
|
Spacer(minLength: 80)
|
||||||
ErrorBanner(message: error) {
|
|
||||||
Task { await viewModel.load() }
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.top, 8)
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.load()
|
await vm.load()
|
||||||
}
|
|
||||||
|
|
||||||
// Floating add button
|
|
||||||
addButton
|
|
||||||
}
|
}
|
||||||
|
.background(Color.canvas)
|
||||||
.task {
|
.task {
|
||||||
await viewModel.load()
|
await vm.load()
|
||||||
|
}
|
||||||
|
.onChange(of: vm.selectedDate) {
|
||||||
|
Task { await vm.load() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Date Selector
|
|
||||||
|
|
||||||
private var dateSelector: some View {
|
private var dateSelector: some View {
|
||||||
HStack(spacing: 0) {
|
HStack {
|
||||||
Button {
|
Button {
|
||||||
viewModel.goToPreviousDay()
|
vm.goToPreviousDay()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.body.weight(.semibold))
|
.font(.title3.weight(.medium))
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(viewModel.selectedDate.relativeLabel)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
if !viewModel.selectedDate.isToday {
|
|
||||||
Text(viewModel.selectedDate.displayString)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
viewModel.goToToday()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.goToNextDay()
|
vm.goToToday()
|
||||||
|
} label: {
|
||||||
|
Text(vm.displayDateString)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
vm.goToNextDay()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.body.weight(.semibold))
|
.font(.title3.weight(.medium))
|
||||||
.foregroundStyle(
|
.foregroundStyle(Color.accentWarm)
|
||||||
viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm
|
|
||||||
)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
}
|
}
|
||||||
.disabled(viewModel.selectedDate.isToday)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 8)
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Macro Summary
|
private var macroSummary: some View {
|
||||||
|
|
||||||
private var macroSummaryCard: some View {
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Calories ring
|
HStack(spacing: 24) {
|
||||||
HStack(spacing: 20) {
|
MacroRingWithLabel(
|
||||||
MacroRingLarge(
|
consumed: vm.totalCalories,
|
||||||
current: viewModel.totalCalories,
|
goal: vm.calorieGoal,
|
||||||
goal: viewModel.goal.calories,
|
label: "kcal",
|
||||||
color: .caloriesColor,
|
color: .emerald,
|
||||||
size: 100,
|
size: 90,
|
||||||
lineWidth: 9
|
lineWidth: 9
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
macroRow("Protein", current: viewModel.totalProtein, goal: viewModel.goal.protein, color: .proteinColor)
|
MacroBar(
|
||||||
macroRow("Carbs", current: viewModel.totalCarbs, goal: viewModel.goal.carbs, color: .carbsColor)
|
label: "Protein",
|
||||||
macroRow("Fat", current: viewModel.totalFat, goal: viewModel.goal.fat, color: .fatColor)
|
consumed: vm.totalProtein,
|
||||||
}
|
goal: vm.proteinGoal,
|
||||||
.frame(maxWidth: .infinity)
|
color: .macroProtein
|
||||||
|
)
|
||||||
|
MacroBar(
|
||||||
|
label: "Carbs",
|
||||||
|
consumed: vm.totalCarbs,
|
||||||
|
goal: vm.carbsGoal,
|
||||||
|
color: .macroCarbs
|
||||||
|
)
|
||||||
|
MacroBar(
|
||||||
|
label: "Fat",
|
||||||
|
consumed: vm.totalFat,
|
||||||
|
goal: vm.fatGoal,
|
||||||
|
color: .macroFat
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
private func macroRow(_ label: String, current: Double, goal: Double, color: Color) -> some View {
|
.background(Color.surfaceCard)
|
||||||
VStack(spacing: 4) {
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
HStack {
|
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
|
||||||
Text(label)
|
.padding(.horizontal)
|
||||||
.font(.caption.weight(.medium))
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(current))/\(Int(goal))g")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(Color.text2)
|
|
||||||
}
|
|
||||||
MacroBarCompact(current: current, goal: goal, color: color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Add Button
|
|
||||||
|
|
||||||
private var addButton: some View {
|
|
||||||
Button {
|
|
||||||
showFoodSearch = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.title2.weight(.semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
.background(Color.accentWarm)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +1,122 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(AuthManager.self) private var authManager
|
@Environment(AuthManager.self) private var auth
|
||||||
@State private var viewModel = HomeViewModel()
|
@State private var vm = HomeViewModel()
|
||||||
|
@State private var showProfileMenu = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
ZStack {
|
||||||
ScrollView {
|
// Background
|
||||||
VStack(spacing: 20) {
|
if let bg = vm.backgroundImage {
|
||||||
if viewModel.isLoading {
|
Image(uiImage: bg)
|
||||||
LoadingView(message: "Loading dashboard...")
|
.resizable()
|
||||||
.frame(height: 300)
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.ignoresSafeArea()
|
||||||
} else {
|
} else {
|
||||||
// Quick Stats Card
|
Color.canvas.ignoresSafeArea()
|
||||||
caloriesSummaryCard
|
|
||||||
|
|
||||||
// Macros Card
|
|
||||||
macrosCard
|
|
||||||
|
|
||||||
// Quick Actions
|
|
||||||
quickActionsCard
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = viewModel.errorMessage {
|
ScrollView {
|
||||||
ErrorBanner(message: error) {
|
VStack(spacing: 16) {
|
||||||
Task { await viewModel.load() }
|
// Top bar
|
||||||
}
|
HStack {
|
||||||
}
|
Text("Home")
|
||||||
}
|
.font(.largeTitle.weight(.bold))
|
||||||
.padding(16)
|
.foregroundStyle(vm.hasBackground ? .white : Color.textPrimary)
|
||||||
}
|
Spacer()
|
||||||
.background(Color.canvas)
|
|
||||||
.navigationTitle("Dashboard")
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Menu {
|
Menu {
|
||||||
|
PhotosPicker(
|
||||||
|
selection: $vm.selectedPhoto,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
|
Label("Change Background", systemImage: "photo")
|
||||||
|
}
|
||||||
|
if vm.hasBackground {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
authManager.logout()
|
vm.removeBackground()
|
||||||
|
} label: {
|
||||||
|
Label("Remove Background", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await auth.logout() }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "person.circle.fill")
|
Image(systemName: "person.circle.fill")
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
await viewModel.load()
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await viewModel.load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var caloriesSummaryCard: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Today")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
Text(Date().displayString)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Text("\(viewModel.entryCount) entries")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.surfaceSecondary)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
|
|
||||||
MacroRingLarge(
|
|
||||||
current: viewModel.totalCalories,
|
|
||||||
goal: viewModel.goal.calories,
|
|
||||||
color: .caloriesColor,
|
|
||||||
size: 140,
|
|
||||||
lineWidth: 12
|
|
||||||
)
|
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal")
|
|
||||||
Spacer()
|
|
||||||
macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal")
|
|
||||||
Spacer()
|
|
||||||
macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var macrosCard: some View {
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
Text("Macros")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
HStack(spacing: 20) {
|
|
||||||
MacroRing(
|
|
||||||
current: viewModel.totalProtein,
|
|
||||||
goal: viewModel.goal.protein,
|
|
||||||
color: .proteinColor,
|
|
||||||
label: "Protein",
|
|
||||||
unit: "g",
|
|
||||||
size: 68
|
|
||||||
)
|
|
||||||
MacroRing(
|
|
||||||
current: viewModel.totalCarbs,
|
|
||||||
goal: viewModel.goal.carbs,
|
|
||||||
color: .carbsColor,
|
|
||||||
label: "Carbs",
|
|
||||||
unit: "g",
|
|
||||||
size: 68
|
|
||||||
)
|
|
||||||
MacroRing(
|
|
||||||
current: viewModel.totalFat,
|
|
||||||
goal: viewModel.goal.fat,
|
|
||||||
color: .fatColor,
|
|
||||||
label: "Fat",
|
|
||||||
unit: "g",
|
|
||||||
size: 68
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var quickActionsCard: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Text("Quick Actions")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald)
|
|
||||||
quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor)
|
|
||||||
quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
.background(Color.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func macroStat(_ label: String, value: Int, unit: String) -> some View {
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text("\(value)")
|
|
||||||
.font(.system(.title3, design: .rounded, weight: .bold))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
Text("\(label)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func quickActionButton(icon: String, label: String, color: Color) -> some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(vm.hasBackground ? .white : Color.accentWarm)
|
||||||
Text(label)
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 60)
|
||||||
|
|
||||||
|
// Widget grid
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
], spacing: 12) {
|
||||||
|
// Calorie widget
|
||||||
|
calorieWidget
|
||||||
|
.gridCellColumns(2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer(minLength: 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.task {
|
||||||
|
await vm.loadTodayData()
|
||||||
|
}
|
||||||
|
.onChange(of: vm.selectedPhoto) {
|
||||||
|
Task { await vm.handlePhotoSelection() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var calorieWidget: some View {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
MacroRingWithLabel(
|
||||||
|
consumed: vm.totalCalories,
|
||||||
|
goal: vm.calorieGoal,
|
||||||
|
label: "kcal",
|
||||||
|
color: .emerald,
|
||||||
|
size: 100,
|
||||||
|
lineWidth: 10
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Calories")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(vm.hasBackground ? .white : Color.textPrimary)
|
||||||
|
|
||||||
|
Text("\(Int(vm.totalCalories)) / \(Int(vm.calorieGoal))")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(vm.hasBackground ? .white.opacity(0.8) : Color.textSecondary)
|
||||||
|
|
||||||
|
let remaining = max(vm.calorieGoal - vm.totalCalories, 0)
|
||||||
|
Text("\(Int(remaining)) remaining")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.foregroundStyle(vm.hasBackground ? .white.opacity(0.6) : Color.textTertiary)
|
||||||
.foregroundStyle(Color.text2)
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background {
|
||||||
|
if vm.hasBackground {
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color.surfaceCard)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(color.opacity(0.06))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,74 @@
|
|||||||
import Foundation
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
@MainActor @Observable
|
@Observable
|
||||||
final class HomeViewModel {
|
final class HomeViewModel {
|
||||||
var todayEntries: [FoodEntry] = []
|
|
||||||
var goal: DailyGoal = DailyGoal()
|
|
||||||
var isLoading = true
|
|
||||||
var errorMessage: String?
|
|
||||||
|
|
||||||
private let repo = FitnessRepository.shared
|
private let repo = FitnessRepository.shared
|
||||||
|
|
||||||
var totalCalories: Double {
|
var totalCalories: Double = 0
|
||||||
todayEntries.reduce(0) { $0 + $1.calories }
|
var calorieGoal: Double = 2000
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
|
// Background image
|
||||||
|
var backgroundImage: UIImage?
|
||||||
|
var selectedPhoto: PhotosPickerItem?
|
||||||
|
|
||||||
|
private let backgroundKey = "homeBackgroundImage"
|
||||||
|
|
||||||
|
init() {
|
||||||
|
loadSavedBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalProtein: Double {
|
var hasBackground: Bool {
|
||||||
todayEntries.reduce(0) { $0 + $1.protein }
|
backgroundImage != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCarbs: Double {
|
func loadTodayData() async {
|
||||||
todayEntries.reduce(0) { $0 + $1.carbs }
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalFat: Double {
|
|
||||||
todayEntries.reduce(0) { $0 + $1.fat }
|
|
||||||
}
|
|
||||||
|
|
||||||
var entryCount: Int {
|
|
||||||
todayEntries.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func load() async {
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
|
||||||
let today = Date().apiDateString
|
let today = Date().apiDateString
|
||||||
|
async let entriesTask: () = repo.loadEntries(date: today)
|
||||||
do {
|
async let goalTask: () = repo.loadGoal(date: today)
|
||||||
async let entriesTask = repo.entries(for: today, forceRefresh: true)
|
_ = await (entriesTask, goalTask)
|
||||||
async let goalsTask = repo.goals(for: today)
|
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
|
||||||
|
calorieGoal = repo.goal?.calories ?? 2000
|
||||||
todayEntries = try await entriesTask
|
|
||||||
goal = try await goalsTask
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Background Image
|
||||||
|
|
||||||
|
func handlePhotoSelection() async {
|
||||||
|
guard let item = selectedPhoto else { return }
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self) else { return }
|
||||||
|
guard let original = UIImage(data: data) else { return }
|
||||||
|
|
||||||
|
let resized = resizeImage(original, maxWidth: 1200)
|
||||||
|
backgroundImage = resized
|
||||||
|
|
||||||
|
if let jpegData = resized.jpegData(compressionQuality: 0.8) {
|
||||||
|
UserDefaults.standard.set(jpegData, forKey: backgroundKey)
|
||||||
|
}
|
||||||
|
selectedPhoto = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeBackground() {
|
||||||
|
backgroundImage = nil
|
||||||
|
UserDefaults.standard.removeObject(forKey: backgroundKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSavedBackground() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: backgroundKey),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
backgroundImage = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage {
|
||||||
|
let scale = maxWidth / image.size.width
|
||||||
|
guard scale < 1 else { return image }
|
||||||
|
let newSize = CGSize(width: maxWidth, height: image.size.height * scale)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||||
|
return renderer.image { _ in
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,45 +4,41 @@ struct LoadingView: View {
|
|||||||
var message: String = "Loading..."
|
var message: String = "Loading..."
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 12) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.large)
|
.controlSize(.regular)
|
||||||
.tint(Color.accentWarm)
|
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.canvas)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ErrorBanner: View {
|
struct ErrorBanner: View {
|
||||||
let message: String
|
let message: String
|
||||||
var onRetry: (() -> Void)?
|
var retry: (() async -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Color.error)
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.text2)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if let retry {
|
||||||
if let onRetry {
|
|
||||||
Button("Retry") {
|
Button("Retry") {
|
||||||
onRetry()
|
Task { await retry() }
|
||||||
}
|
}
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding()
|
||||||
.background(Color.error.opacity(0.06))
|
.background(Color.orange.opacity(0.1))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,18 +51,16 @@ struct EmptyStateView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundStyle(Color.text4)
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(Color.text2)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Color.text3)
|
.foregroundStyle(Color.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.padding(40)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,74 +2,41 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MacroBar: View {
|
struct MacroBar: View {
|
||||||
let label: String
|
let label: String
|
||||||
let current: Double
|
let consumed: Double
|
||||||
let goal: Double
|
let goal: Double
|
||||||
let color: Color
|
var color: Color = .emerald
|
||||||
var showGrams: Bool = true
|
var unit: String = "g"
|
||||||
|
|
||||||
private var progress: Double {
|
private var progress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(current / goal, 1.0)
|
return min(max(consumed / goal, 0), 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption.weight(.medium))
|
||||||
.fontWeight(.medium)
|
.foregroundStyle(Color.textSecondary)
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
if showGrams {
|
Text("\(Int(consumed))/\(Int(goal))\(unit)")
|
||||||
Text("\(Int(current))g / \(Int(goal))g")
|
.font(.caption.weight(.semibold))
|
||||||
.font(.caption)
|
.foregroundStyle(Color.textPrimary)
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
} else {
|
|
||||||
Text("\(Int(current)) / \(Int(goal))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
Capsule()
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(color.opacity(0.12))
|
.fill(color.opacity(0.15))
|
||||||
.frame(height: 6)
|
.frame(height: 8)
|
||||||
|
|
||||||
Capsule()
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(color)
|
.fill(color)
|
||||||
.frame(width: geo.size.width * progress, height: 6)
|
.frame(width: geo.size.width * progress, height: 8)
|
||||||
.animation(.easeOut(duration: 0.5), value: progress)
|
.animation(.easeInOut(duration: 0.5), value: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 6)
|
.frame(height: 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MacroBarCompact: View {
|
|
||||||
let current: Double
|
|
||||||
let goal: Double
|
|
||||||
let color: Color
|
|
||||||
|
|
||||||
private var progress: Double {
|
|
||||||
guard goal > 0 else { return 0 }
|
|
||||||
return min(current / goal, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geo in
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
Capsule()
|
|
||||||
.fill(color.opacity(0.12))
|
|
||||||
|
|
||||||
Capsule()
|
|
||||||
.fill(color)
|
|
||||||
.frame(width: geo.size.width * progress)
|
|
||||||
.animation(.easeOut(duration: 0.5), value: progress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MacroRing: View {
|
struct MacroRing: View {
|
||||||
let current: Double
|
let consumed: Double
|
||||||
let goal: Double
|
let goal: Double
|
||||||
let color: Color
|
var lineWidth: CGFloat = 10
|
||||||
let label: String
|
var color: Color = .emerald
|
||||||
let unit: String
|
var size: CGFloat = 100
|
||||||
var size: CGFloat = 72
|
|
||||||
var lineWidth: CGFloat = 7
|
|
||||||
|
|
||||||
private var progress: Double {
|
private var progress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(current / goal, 1.0)
|
return min(max(consumed / goal, 0), 1.0)
|
||||||
}
|
|
||||||
|
|
||||||
private var remaining: Double {
|
|
||||||
max(goal - current, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 4) {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: progress)
|
.trim(from: 0, to: progress)
|
||||||
@@ -31,66 +24,38 @@ struct MacroRing: View {
|
|||||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.easeOut(duration: 0.5), value: progress)
|
.animation(.easeInOut(duration: 0.6), value: progress)
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Text("\(Int(remaining))")
|
|
||||||
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(Color.text1)
|
|
||||||
Text("left")
|
|
||||||
.font(.system(size: size * 0.13, weight: .medium))
|
|
||||||
.foregroundStyle(Color.text4)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
|
|
||||||
Text(label)
|
|
||||||
.font(.caption2)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundStyle(Color.text3)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MacroRingLarge: View {
|
struct MacroRingWithLabel: View {
|
||||||
let current: Double
|
let consumed: Double
|
||||||
let goal: Double
|
let goal: Double
|
||||||
let color: Color
|
let label: String
|
||||||
var size: CGFloat = 120
|
var color: Color = .emerald
|
||||||
|
var size: CGFloat = 100
|
||||||
var lineWidth: CGFloat = 10
|
var lineWidth: CGFloat = 10
|
||||||
|
|
||||||
private var progress: Double {
|
|
||||||
guard goal > 0 else { return 0 }
|
|
||||||
return min(current / goal, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var remaining: Double {
|
|
||||||
max(goal - current, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
MacroRing(
|
||||||
.stroke(color.opacity(0.12), lineWidth: lineWidth)
|
consumed: consumed,
|
||||||
|
goal: goal,
|
||||||
Circle()
|
lineWidth: lineWidth,
|
||||||
.trim(from: 0, to: progress)
|
color: color,
|
||||||
.stroke(
|
size: size
|
||||||
color,
|
|
||||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
.animation(.easeOut(duration: 0.5), value: progress)
|
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text("\(Int(remaining))")
|
Text("\(Int(consumed))")
|
||||||
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
|
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(Color.text1)
|
.foregroundStyle(Color.textPrimary)
|
||||||
Text("remaining")
|
Text(label)
|
||||||
.font(.system(size: size * 0.11, weight: .medium))
|
.font(.system(size: size * 0.1, weight: .medium))
|
||||||
.foregroundStyle(Color.text4)
|
.foregroundStyle(Color.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,40 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
// Warm palette matching web app
|
// MARK: - Canvas / Background
|
||||||
static let canvas = Color(hex: "F5EFE6")
|
static let canvas = Color(red: 0.96, green: 0.94, blue: 0.90) // #F5EFE6
|
||||||
static let surface = Color.white
|
|
||||||
static let surfaceSecondary = Color(hex: "F4F4F5")
|
|
||||||
static let cardBackground = Color.white
|
|
||||||
static let cardSecondary = Color(hex: "F4F4F5")
|
|
||||||
|
|
||||||
static let text1 = Color(hex: "18181B")
|
// MARK: - Accent
|
||||||
static let text2 = Color(hex: "3F3F46")
|
static let accentWarm = Color(red: 0.545, green: 0.412, blue: 0.078) // #8B6914
|
||||||
static let text3 = Color(hex: "71717A")
|
static let emerald = Color(red: 0.020, green: 0.588, blue: 0.412) // #059669
|
||||||
static let text4 = Color(hex: "A1A1AA")
|
|
||||||
|
|
||||||
// Accent — warm amber/brown
|
// MARK: - Surfaces
|
||||||
static let accentWarm = Color(hex: "8B6914")
|
static let surfaceCard = Color.white
|
||||||
static let accentWarmBg = Color(hex: "FEF7E6")
|
static let surfaceSheet = Color(red: 0.98, green: 0.97, blue: 0.95)
|
||||||
|
|
||||||
// Emerald accent from web
|
// MARK: - Text
|
||||||
static let accentEmerald = Color(hex: "059669")
|
static let textPrimary = Color(red: 0.12, green: 0.12, blue: 0.12)
|
||||||
static let accentEmeraldBg = Color(hex: "ECFDF5")
|
static let textSecondary = Color(red: 0.45, green: 0.45, blue: 0.45)
|
||||||
|
static let textTertiary = Color(red: 0.65, green: 0.65, blue: 0.65)
|
||||||
|
|
||||||
// Semantic
|
// MARK: - Meal Colors
|
||||||
static let success = Color(hex: "059669")
|
static let mealBreakfast = Color(red: 1.0, green: 0.72, blue: 0.27) // warm orange
|
||||||
static let error = Color(hex: "DC2626")
|
static let mealLunch = Color(red: 0.30, green: 0.69, blue: 0.31) // green
|
||||||
static let warning = Color(hex: "D97706")
|
static let mealDinner = Color(red: 0.40, green: 0.35, blue: 0.80) // purple
|
||||||
|
static let mealSnack = Color(red: 0.93, green: 0.46, blue: 0.46) // coral
|
||||||
|
|
||||||
// Macro colors
|
// MARK: - Macros
|
||||||
static let caloriesColor = Color(hex: "8B6914")
|
static let macroProtein = Color(red: 0.35, green: 0.56, blue: 0.91) // blue
|
||||||
static let proteinColor = Color(hex: "059669")
|
static let macroCarbs = Color(red: 0.96, green: 0.65, blue: 0.14) // amber
|
||||||
static let carbsColor = Color(hex: "3B82F6")
|
static let macroFat = Color(red: 0.85, green: 0.35, blue: 0.45) // pink-red
|
||||||
static let fatColor = Color(hex: "F59E0B")
|
|
||||||
static let sugarColor = Color(hex: "EC4899")
|
|
||||||
static let fiberColor = Color(hex: "8B5CF6")
|
|
||||||
|
|
||||||
// Meal colors
|
static func mealColor(for mealType: String) -> Color {
|
||||||
static let breakfast = Color(hex: "F59E0B")
|
switch mealType.lowercased() {
|
||||||
static let lunch = Color(hex: "059669")
|
case "breakfast": return .mealBreakfast
|
||||||
static let dinner = Color(hex: "3B82F6")
|
case "lunch": return .mealLunch
|
||||||
static let snack = Color(hex: "8B5CF6")
|
case "dinner": return .mealDinner
|
||||||
static let breakfastColor = Color(hex: "F59E0B")
|
case "snack": return .mealSnack
|
||||||
static let lunchColor = Color(hex: "059669")
|
default: return .accentWarm
|
||||||
static let dinnerColor = Color(hex: "3B82F6")
|
|
||||||
static let snackColor = Color(hex: "8B5CF6")
|
|
||||||
|
|
||||||
init(hex: String) {
|
|
||||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
||||||
var int: UInt64 = 0
|
|
||||||
Scanner(string: hex).scanHexInt64(&int)
|
|
||||||
let a, r, g, b: UInt64
|
|
||||||
switch hex.count {
|
|
||||||
case 3:
|
|
||||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
|
||||||
case 6:
|
|
||||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
|
||||||
case 8:
|
|
||||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
|
||||||
default:
|
|
||||||
(a, r, g, b) = (255, 0, 0, 0)
|
|
||||||
}
|
|
||||||
self.init(
|
|
||||||
.sRGB,
|
|
||||||
red: Double(r) / 255,
|
|
||||||
green: Double(g) / 255,
|
|
||||||
blue: Double(b) / 255,
|
|
||||||
opacity: Double(a) / 255
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func mealColor(for meal: String) -> Color {
|
|
||||||
switch meal.lowercased() {
|
|
||||||
case "breakfast": return .breakfast
|
|
||||||
case "lunch": return .lunch
|
|
||||||
case "dinner": return .dinner
|
|
||||||
case "snack": return .snack
|
|
||||||
default: return .text3
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func mealColor(for meal: MealType) -> Color {
|
|
||||||
mealColor(for: meal.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func mealIcon(for meal: String) -> String {
|
|
||||||
switch meal.lowercased() {
|
|
||||||
case "breakfast": return "sunrise.fill"
|
|
||||||
case "lunch": return "sun.max.fill"
|
|
||||||
case "dinner": return "moon.fill"
|
|
||||||
case "snack": return "leaf.fill"
|
|
||||||
default: return "fork.knife"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func mealIcon(for meal: MealType) -> String {
|
|
||||||
mealIcon(for: meal.rawValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,28 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Date {
|
extension Date {
|
||||||
/// Format as yyyy-MM-dd for API calls
|
private static let apiFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let displayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "EEE, MMM d"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
var apiDateString: String {
|
var apiDateString: String {
|
||||||
let formatter = DateFormatter()
|
Self.apiFormatter.string(from: self)
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display format: "Mon, Apr 2"
|
|
||||||
var displayString: String {
|
var displayString: String {
|
||||||
let formatter = DateFormatter()
|
Self.displayFormatter.string(from: self)
|
||||||
formatter.dateFormat = "EEE, MMM d"
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full display: "Monday, April 2, 2026"
|
static func fromAPI(_ string: String) -> Date? {
|
||||||
var fullDisplayString: String {
|
apiFormatter.date(from: string)
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .full
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short display: "Apr 2"
|
|
||||||
var shortDisplayString: String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "MMM d"
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isToday: Bool {
|
|
||||||
Calendar.current.isDateInToday(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isYesterday: Bool {
|
|
||||||
Calendar.current.isDateInYesterday(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func adding(days: Int) -> Date {
|
|
||||||
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
|
|
||||||
}
|
|
||||||
|
|
||||||
static func from(apiString: String) -> Date? {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return formatter.date(from: apiString)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a label like "Today", "Yesterday", or the display string
|
|
||||||
var relativeLabel: String {
|
|
||||||
if isToday { return "Today" }
|
|
||||||
if isYesterday { return "Yesterday" }
|
|
||||||
return displayString
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
services/media/Dockerfile.api
Normal file
22
services/media/Dockerfile.api
Normal 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"]
|
||||||
18
services/media/Dockerfile.worker
Normal file
18
services/media/Dockerfile.worker
Normal 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"]
|
||||||
0
services/media/app/__init__.py
Normal file
0
services/media/app/__init__.py
Normal file
0
services/media/app/api/__init__.py
Normal file
0
services/media/app/api/__init__.py
Normal file
21
services/media/app/api/deps.py
Normal file
21
services/media/app/api/deps.py
Normal 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
|
||||||
232
services/media/app/api/episodes.py
Normal file
232
services/media/app/api/episodes.py
Normal 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
229
services/media/app/api/playback.py
Normal file
229
services/media/app/api/playback.py
Normal 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}
|
||||||
236
services/media/app/api/queue.py
Normal file
236
services/media/app/api/queue.py
Normal 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"}
|
||||||
519
services/media/app/api/shows.py
Normal file
519
services/media/app/api/shows.py
Normal 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}
|
||||||
35
services/media/app/config.py
Normal file
35
services/media/app/config.py
Normal 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",
|
||||||
|
}
|
||||||
18
services/media/app/database.py
Normal file
18
services/media/app/database.py
Normal 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
|
||||||
87
services/media/app/main.py
Normal file
87
services/media/app/main.py
Normal 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,
|
||||||
|
}
|
||||||
109
services/media/app/models.py
Normal file
109
services/media/app/models.py
Normal 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")
|
||||||
0
services/media/app/worker/__init__.py
Normal file
0
services/media/app/worker/__init__.py
Normal file
298
services/media/app/worker/tasks.py
Normal file
298
services/media/app/worker/tasks.py
Normal 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()
|
||||||
44
services/media/docker-compose.yml
Normal file
44
services/media/docker-compose.yml
Normal 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
|
||||||
12
services/media/requirements.txt
Normal file
12
services/media/requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user