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