diff --git a/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.pbxproj b/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c7dd7b4 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.pbxproj @@ -0,0 +1,555 @@ +// !$*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 */; }; +/* 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 = ""; }; +/* 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 = ( + 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 */, + ); + 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 */, + 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 */, + A10008 /* HomeViewModel.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 */, + ); + 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 1.44.16 AM/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.xcworkspace/xcuserdata/yusufsuleman.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.xcworkspace/xcuserdata/yusufsuleman.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..f6b8c5a Binary files /dev/null and b/ios/Platform 1.44.16 AM/Platform.xcodeproj/project.xcworkspace/xcuserdata/yusufsuleman.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Platform 1.44.16 AM/Platform.xcodeproj/xcuserdata/yusufsuleman.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/Platform 1.44.16 AM/Platform.xcodeproj/xcuserdata/yusufsuleman.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..134d4fa --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform.xcodeproj/xcuserdata/yusufsuleman.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Platform.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..ffb319c --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.078", + "green" : "0.412", + "red" : "0.545" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.157", + "green" : "0.502", + "red" : "0.667" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/Contents.json b/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Config.swift b/ios/Platform 1.44.16 AM/Platform/Config.swift new file mode 100644 index 0000000..bdf83ae --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Config.swift @@ -0,0 +1,15 @@ +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) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/ContentView.swift b/ios/Platform 1.44.16 AM/Platform/ContentView.swift new file mode 100644 index 0000000..f80b2a2 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/ContentView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct ContentView: View { + @Environment(AuthManager.self) private var authManager + + var body: some View { + Group { + if authManager.isCheckingAuth { + LoadingView(message: "Checking session...") + } else if authManager.isAuthenticated { + MainTabView() + } else { + LoginView() + } + } + .task { + await authManager.checkAuth() + } + } +} + +struct MainTabView: View { + var body: some View { + TabView { + HomeView() + .tabItem { + Label("Home", systemImage: "house.fill") + } + + FitnessTabView() + .tabItem { + Label("Fitness", systemImage: "flame.fill") + } + } + .tint(Color.accentWarm) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Core/APIClient.swift b/ios/Platform 1.44.16 AM/Platform/Core/APIClient.swift new file mode 100644 index 0000000..fbe8950 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Core/APIClient.swift @@ -0,0 +1,174 @@ +import Foundation + +enum APIError: LocalizedError { + case invalidURL + case httpError(statusCode: Int, body: String?) + case decodingError(Error) + case networkError(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .httpError(let code, let body): + return "HTTP \(code): \(body ?? "Unknown error")" + case .decodingError(let error): + return "Decoding error: \(error.localizedDescription)" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + } + } +} + +final class APIClient: @unchecked Sendable { + static let shared = APIClient() + + let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + config.httpCookieAcceptPolicy = .always + config.httpShouldSetCookies = true + config.httpCookieStorage = .shared + config.timeoutIntervalForRequest = 30 + session = URLSession(configuration: config) + } + + // MARK: - JSON Decoder + + private var decoder: JSONDecoder { + let d = JSONDecoder() + return d + } + + // MARK: - Core Request + + func request( + _ method: String, + path: String, + body: (any Encodable)? = nil, + queryItems: [URLQueryItem]? = nil + ) async throws -> T { + let url = Config.apiURL(path) + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + if let queryItems { + components.queryItems = queryItems + } + + guard let finalURL = components.url else { + throw APIError.invalidURL + } + + var request = URLRequest(url: finalURL) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + } + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.networkError(URLError(.badServerResponse)) + } + + guard (200...299).contains(httpResponse.statusCode) else { + let body = String(data: data, encoding: .utf8) + throw APIError.httpError(statusCode: httpResponse.statusCode, body: body) + } + + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + } catch let error as APIError { + throw error + } catch { + throw APIError.networkError(error) + } + } + + // 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)? = nil, + queryItems: [URLQueryItem]? = nil + ) async throws -> T { + try await request("POST", path: path, body: body, queryItems: queryItems) + } + + func patch( + _ path: String, + body: (any Encodable)? = nil + ) async throws -> T { + try await request("PATCH", path: path, body: body) + } + + func delete(_ path: String) async throws { + let url = Config.apiURL(path) + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.networkError(URLError(.badServerResponse)) + } + guard (200...299).contains(httpResponse.statusCode) else { + let body = String(data: data, encoding: .utf8) + throw APIError.httpError(statusCode: httpResponse.statusCode, body: body) + } + } + + // POST that returns no meaningful body (or we ignore it) + func postVoid( + _ path: String, + body: (any Encodable)? = nil, + queryItems: [URLQueryItem]? = nil + ) async throws { + let url = Config.apiURL(path) + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + if let queryItems { + components.queryItems = queryItems + } + guard let finalURL = components.url else { + throw APIError.invalidURL + } + var request = URLRequest(url: finalURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + } + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.networkError(URLError(.badServerResponse)) + } + guard (200...299).contains(httpResponse.statusCode) else { + let bodyStr = String(data: data, encoding: .utf8) + throw APIError.httpError(statusCode: httpResponse.statusCode, body: bodyStr) + } + } + + // MARK: - Cookie Management + + func clearCookies() { + guard let cookies = HTTPCookieStorage.shared.cookies(for: Config.gatewayURL) else { return } + for cookie in cookies { + HTTPCookieStorage.shared.deleteCookie(cookie) + } + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Core/AuthManager.swift b/ios/Platform 1.44.16 AM/Platform/Core/AuthManager.swift new file mode 100644 index 0000000..bd16d4e --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Core/AuthManager.swift @@ -0,0 +1,114 @@ +import Foundation +import SwiftUI + +@MainActor @Observable +final class AuthManager { + var isAuthenticated = false + var isCheckingAuth = true + var user: AuthUser? + var loginError: String? + + private let api = APIClient.shared + + struct LoginRequest: Encodable { + let username: String + let password: String + } + + struct LoginResponse: Decodable { + let success: Bool? + let user: AuthUser? + let error: String? + } + + struct AuthResponse: Decodable { + let authenticated: Bool? + let user: AuthUser? + } + + struct AuthUser: Decodable { + let id: IntOrString + let username: String? + let displayName: String? + + enum CodingKeys: String, CodingKey { + case id, username + case displayName = "display_name" + } + } + + // Gateway returns id as Int, handle both Int and String + struct IntOrString: Decodable { + let value: String + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intVal = try? container.decode(Int.self) { + value = String(intVal) + } else { + value = try container.decode(String.self) + } + } + } + + func checkAuth() async { + isCheckingAuth = true + defer { isCheckingAuth = false } + + // Check if we had a previous session + guard UserDefaults.standard.bool(forKey: "is_logged_in") else { + isAuthenticated = false + return + } + + do { + let response: AuthResponse = try await api.get("/api/auth/me") + if response.authenticated == true { + isAuthenticated = true + user = response.user + } else { + isAuthenticated = false + UserDefaults.standard.set(false, forKey: "is_logged_in") + } + } catch { + isAuthenticated = false + UserDefaults.standard.set(false, forKey: "is_logged_in") + } + } + + func login(username: String, password: String) async { + loginError = nil + + do { + let body = LoginRequest(username: username, password: password) + let loginResp: LoginResponse = try await api.post("/api/auth/login", body: body) + + if loginResp.success == true { + user = loginResp.user + isAuthenticated = true + UserDefaults.standard.set(true, forKey: "is_logged_in") + } else { + loginError = loginResp.error ?? "Authentication failed" + } + } catch let error as APIError { + switch error { + case .httpError(let code, _): + if code == 401 { + loginError = "Invalid username or password" + } else { + loginError = "Server error (\(code))" + } + default: + loginError = error.localizedDescription + } + } catch { + loginError = error.localizedDescription + } + } + + func logout() { + api.clearCookies() + isAuthenticated = false + user = nil + UserDefaults.standard.set(false, forKey: "is_logged_in") + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Auth/LoginView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Auth/LoginView.swift new file mode 100644 index 0000000..71d5e48 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Auth/LoginView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct LoginView: View { + @Environment(AuthManager.self) private var authManager + + @State private var username = "" + @State private var password = "" + @State private var isLoading = false + @FocusState private var focusedField: Field? + + private enum Field: Hashable { + case username, password + } + + var body: some View { + ZStack { + Color.canvas + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 32) { + Spacer() + .frame(height: 60) + + // Logo / Branding + VStack(spacing: 8) { + Image(systemName: "square.grid.2x2.fill") + .font(.system(size: 48)) + .foregroundStyle(Color.accentWarm) + + Text("Platform") + .font(.largeTitle.weight(.bold)) + .foregroundStyle(Color.text1) + + Text("Sign in to your dashboard") + .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() + } + .padding(.horizontal, 28) + } + } + .onSubmit { + switch focusedField { + case .username: + focusedField = .password + case .password: + performLogin() + case .none: + break + } + } + } + + private var canSubmit: Bool { + !username.trimmingCharacters(in: .whitespaces).isEmpty + && !password.isEmpty + && !isLoading + } + + private func performLogin() { + guard canSubmit else { return } + isLoading = true + focusedField = nil + Task { + await authManager.login( + username: username.trimmingCharacters(in: .whitespaces), + password: password + ) + isLoading = false + } + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/API/FitnessAPI.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/API/FitnessAPI.swift new file mode 100644 index 0000000..a989422 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/API/FitnessAPI.swift @@ -0,0 +1,85 @@ +import Foundation + +struct FitnessAPI { + private let api = APIClient.shared + + // MARK: - Entries + + func getEntries(date: String) async throws -> [FoodEntry] { + try await api.get( + "/api/fitness/entries", + queryItems: [URLQueryItem(name: "date", value: date)] + ) + } + + func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry { + try await api.post("/api/fitness/entries", body: request) + } + + func updateEntry(id: String, request: UpdateEntryRequest) async throws -> FoodEntry { + try await api.patch("/api/fitness/entries/\(id)", body: request) + } + + func deleteEntry(id: String) async throws { + try await api.delete("/api/fitness/entries/\(id)") + } + + // MARK: - Foods + + func getFoods(limit: Int = 100) async throws -> [FoodItem] { + try await api.get( + "/api/fitness/foods", + queryItems: [URLQueryItem(name: "limit", value: "\(limit)")] + ) + } + + func searchFoods(query: String, limit: Int = 20) async throws -> [FoodItem] { + try await api.get( + "/api/fitness/foods/search", + queryItems: [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: "\(limit)") + ] + ) + } + + func getFood(id: String) async throws -> FoodItem { + try await api.get("/api/fitness/foods/\(id)") + } + + func getRecentFoods(limit: Int = 8) async throws -> [FoodItem] { + try await api.get( + "/api/fitness/foods/recent", + queryItems: [URLQueryItem(name: "limit", value: "\(limit)")] + ) + } + + // MARK: - Goals + + func getGoals(date: String) async throws -> DailyGoal { + try await api.get( + "/api/fitness/goals/for-date", + queryItems: [URLQueryItem(name: "date", value: date)] + ) + } + + // MARK: - Templates + + func getTemplates() async throws -> [MealTemplate] { + try await api.get("/api/fitness/templates") + } + + func logTemplate(id: String, date: String) async throws { + try await api.postVoid( + "/api/fitness/templates/\(id)/log", + queryItems: [URLQueryItem(name: "date", value: date)] + ) + } + + // MARK: - AI Split + + func splitFood(text: String, mealType: String) async throws -> [FoodItem] { + let request = SplitFoodRequest(text: text, mealType: mealType) + return try await api.post("/api/fitness/foods/split", body: request) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Models/FitnessModels.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Models/FitnessModels.swift new file mode 100644 index 0000000..5b87458 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Models/FitnessModels.swift @@ -0,0 +1,359 @@ +import Foundation + +struct FoodEntry: Codable, Identifiable, Hashable { + let id: String + let foodName: String + let servingDescription: String? + let quantity: Double + let unit: String + let mealType: String + let calories: Double + let protein: Double + let carbs: Double + let fat: Double + let sugar: Double? + let fiber: Double? + let entryType: String? + let method: String? + let foodId: String? + let imageUrl: String? + let note: String? + let loggedAt: String? + + enum CodingKeys: String, CodingKey { + case id, quantity, unit, calories, protein, carbs, fat, sugar, fiber, note, method + case foodName = "food_name" + case servingDescription = "serving_description" + case mealType = "meal_type" + case entryType = "entry_type" + case foodId = "food_id" + case imageUrl = "image_url" + case loggedAt = "logged_at" + } + + /// Alternate keys from the snapshot-based API response + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: FlexibleCodingKeys.self) + + id = try container.decode(String.self, forKey: .init("id")) + quantity = try container.decodeIfPresent(Double.self, forKey: .init("quantity")) ?? 1 + unit = try container.decodeIfPresent(String.self, forKey: .init("unit")) ?? "serving" + mealType = try container.decodeIfPresent(String.self, forKey: .init("meal_type")) ?? "snack" + + // Handle both "food_name" and "snapshot_food_name" + if let name = try container.decodeIfPresent(String.self, forKey: .init("food_name")) { + foodName = name + } else if let name = try container.decodeIfPresent(String.self, forKey: .init("snapshot_food_name")) { + foodName = name + } else { + foodName = "Unknown" + } + + servingDescription = try container.decodeIfPresent(String.self, forKey: .init("serving_description")) + ?? container.decodeIfPresent(String.self, forKey: .init("snapshot_serving_label")) + + // Handle both direct and snapshot_ prefixed fields + calories = try container.decodeIfPresent(Double.self, forKey: .init("calories")) + ?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_calories")) + ?? 0 + protein = try container.decodeIfPresent(Double.self, forKey: .init("protein")) + ?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_protein")) + ?? 0 + carbs = try container.decodeIfPresent(Double.self, forKey: .init("carbs")) + ?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_carbs")) + ?? 0 + fat = try container.decodeIfPresent(Double.self, forKey: .init("fat")) + ?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_fat")) + ?? 0 + sugar = try container.decodeIfPresent(Double.self, forKey: .init("sugar")) + ?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_sugar")) + fiber = try container.decodeIfPresent(Double.self, forKey: .init("fiber")) + ?? container.decodeIfPresent(Double.self, forKey: .init("snapshot_fiber")) + + entryType = try container.decodeIfPresent(String.self, forKey: .init("entry_type")) + method = try container.decodeIfPresent(String.self, forKey: .init("entry_method")) + ?? container.decodeIfPresent(String.self, forKey: .init("method")) + foodId = try container.decodeIfPresent(String.self, forKey: .init("food_id")) + imageUrl = try container.decodeIfPresent(String.self, forKey: .init("image_url")) + ?? container.decodeIfPresent(String.self, forKey: .init("food_image_path")) + note = try container.decodeIfPresent(String.self, forKey: .init("note")) + loggedAt = try container.decodeIfPresent(String.self, forKey: .init("logged_at")) + ?? container.decodeIfPresent(String.self, forKey: .init("created_at")) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(foodName, forKey: .foodName) + try container.encodeIfPresent(servingDescription, forKey: .servingDescription) + try container.encode(quantity, forKey: .quantity) + try container.encode(unit, forKey: .unit) + try container.encode(mealType, forKey: .mealType) + try container.encode(calories, forKey: .calories) + try container.encode(protein, forKey: .protein) + try container.encode(carbs, forKey: .carbs) + try container.encode(fat, forKey: .fat) + try container.encodeIfPresent(sugar, forKey: .sugar) + try container.encodeIfPresent(fiber, forKey: .fiber) + try container.encodeIfPresent(entryType, forKey: .entryType) + try container.encodeIfPresent(method, forKey: .method) + try container.encodeIfPresent(foodId, forKey: .foodId) + try container.encodeIfPresent(imageUrl, forKey: .imageUrl) + try container.encodeIfPresent(note, forKey: .note) + try container.encodeIfPresent(loggedAt, forKey: .loggedAt) + } + + static func == (lhs: FoodEntry, rhs: FoodEntry) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +/// Flexible coding keys for handling multiple API shapes +struct FlexibleCodingKeys: 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 = "\(intValue)" + self.intValue = intValue + } +} + +struct FoodItem: Codable, Identifiable { + 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 imageUrl: String? + let favorite: Bool? + + enum CodingKeys: String, CodingKey { + case id, name, brand, status, favorite + case baseUnit = "base_unit" + case caloriesPerBase = "calories_per_base" + case proteinPerBase = "protein_per_base" + case carbsPerBase = "carbs_per_base" + case fatPerBase = "fat_per_base" + case sugarPerBase = "sugar_per_base" + case fiberPerBase = "fiber_per_base" + case imageUrl = "image_url" + } + + var displayUnit: String { + baseUnit ?? "serving" + } + + var displayInfo: String { + let parts = [brand].compactMap { $0 } + let prefix = parts.isEmpty ? "" : "\(parts.joined(separator: " ")) - " + return "\(prefix)\(displayUnit)" + } + + func scaledCalories(quantity: Double) -> Double { + caloriesPerBase * quantity + } + + func scaledProtein(quantity: Double) -> Double { + (proteinPerBase ?? 0) * quantity + } + + func scaledCarbs(quantity: Double) -> Double { + (carbsPerBase ?? 0) * quantity + } + + func scaledFat(quantity: Double) -> Double { + (fatPerBase ?? 0) * quantity + } +} + +struct DailyGoal: Codable { + let calories: Double + let protein: Double + let carbs: Double + let fat: Double + let sugar: Double? + let fiber: Double? + + static let defaultGoal = DailyGoal( + calories: 2000, protein: 150, carbs: 200, fat: 65, sugar: 50, fiber: 30 + ) +} + +struct MealTemplate: Codable, Identifiable { + let id: String + let name: String + let mealType: String + let calories: Double + let protein: Double? + let carbs: Double? + let fat: Double? + let itemsCount: Int? + + // Support flexible decoding for templates with nested items + enum CodingKeys: String, CodingKey { + case id, name, calories, protein, carbs, fat, items + case mealType = "meal_type" + case itemsCount = "items_count" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + mealType = try container.decodeIfPresent(String.self, forKey: .mealType) ?? "snack" + protein = try container.decodeIfPresent(Double.self, forKey: .protein) + carbs = try container.decodeIfPresent(Double.self, forKey: .carbs) + fat = try container.decodeIfPresent(Double.self, forKey: .fat) + + // Try direct calories first, then compute from items + if let directCals = try? container.decode(Double.self, forKey: .calories) { + calories = directCals + itemsCount = try container.decodeIfPresent(Int.self, forKey: .itemsCount) + } else if let items = try? container.decode([TemplateItem].self, forKey: .items) { + calories = items.reduce(0) { $0 + ($1.snapshotCalories ?? 0) * ($1.quantity ?? 1) } + itemsCount = items.count + } else { + calories = 0 + itemsCount = try container.decodeIfPresent(Int.self, forKey: .itemsCount) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(mealType, forKey: .mealType) + try container.encode(calories, forKey: .calories) + try container.encodeIfPresent(protein, forKey: .protein) + try container.encodeIfPresent(carbs, forKey: .carbs) + try container.encodeIfPresent(fat, forKey: .fat) + try container.encodeIfPresent(itemsCount, forKey: .itemsCount) + } +} + +private struct TemplateItem: Codable { + let snapshotCalories: Double? + let quantity: Double? + + enum CodingKeys: String, CodingKey { + case snapshotCalories = "snapshot_calories" + case quantity + } +} + +struct CreateEntryRequest: Encodable { + let foodId: String + let quantity: Double + let unit: String + let mealType: String + let entryDate: String + let entryMethod: String + let source: String + + enum CodingKeys: String, CodingKey { + case quantity, unit, source + case foodId = "food_id" + case mealType = "meal_type" + case entryDate = "entry_date" + case entryMethod = "entry_method" + } +} + +struct UpdateEntryRequest: Encodable { + var quantity: Double? + var unit: String? + var mealType: String? + + enum CodingKeys: String, CodingKey { + case quantity, unit + case mealType = "meal_type" + } +} + +struct SplitFoodRequest: Encodable { + let text: String + let mealType: String + + enum CodingKeys: String, CodingKey { + case text + case mealType = "meal_type" + } +} + +// Meals enum for type safety +enum MealType: String, CaseIterable, Identifiable { + case breakfast + case lunch + case dinner + case snack + + var id: String { rawValue } + + var displayName: String { + rawValue.capitalized + } + + var icon: String { + switch self { + case .breakfast: return "sunrise.fill" + case .lunch: return "sun.max.fill" + case .dinner: return "moon.fill" + case .snack: return "leaf.fill" + } + } + + 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..<20: return .dinner + default: return .snack + } + } +} + +// Helper for grouping entries by meal +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 1.44.16 AM/Platform/Features/Fitness/Repository/FitnessRepository.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Repository/FitnessRepository.swift new file mode 100644 index 0000000..545ca4b --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Repository/FitnessRepository.swift @@ -0,0 +1,104 @@ +import Foundation + +@MainActor @Observable +final class FitnessRepository { + static let shared = FitnessRepository() + + private let api = FitnessAPI() + + // Cached data + var cachedFoods: [FoodItem] = [] + var cachedRecentFoods: [FoodItem] = [] + var cachedTemplates: [MealTemplate] = [] + var cachedGoals: [String: DailyGoal] = [:] + var cachedEntries: [String: [FoodEntry]] = [:] + + private init() {} + + // MARK: - Entries + + func entries(for date: String, forceRefresh: Bool = false) async throws -> [FoodEntry] { + if !forceRefresh, let cached = cachedEntries[date] { + return cached + } + let entries = try await api.getEntries(date: date) + cachedEntries[date] = entries + return entries + } + + func createEntry(_ request: CreateEntryRequest) async throws -> FoodEntry { + let entry = try await api.createEntry(request) + // Invalidate cache for that date + cachedEntries[request.entryDate] = nil + return entry + } + + func updateEntry(id: String, request: UpdateEntryRequest, date: String) async throws -> FoodEntry { + let entry = try await api.updateEntry(id: id, request: request) + cachedEntries[date] = nil + return entry + } + + func deleteEntry(id: String, date: String) async throws { + try await api.deleteEntry(id: id) + cachedEntries[date] = nil + } + + // MARK: - Foods + + func allFoods(forceRefresh: Bool = false) async throws -> [FoodItem] { + if !forceRefresh, !cachedFoods.isEmpty { + return cachedFoods + } + cachedFoods = try await api.getFoods() + return cachedFoods + } + + func searchFoods(query: String) async throws -> [FoodItem] { + try await api.searchFoods(query: query) + } + + func recentFoods(forceRefresh: Bool = false) async throws -> [FoodItem] { + if !forceRefresh, !cachedRecentFoods.isEmpty { + return cachedRecentFoods + } + cachedRecentFoods = try await api.getRecentFoods() + return cachedRecentFoods + } + + // MARK: - Goals + + func goals(for date: String, forceRefresh: Bool = false) async throws -> DailyGoal { + if !forceRefresh, let cached = cachedGoals[date] { + return cached + } + let goal = try await api.getGoals(date: date) + cachedGoals[date] = goal + return goal + } + + // MARK: - Templates + + func templates(forceRefresh: Bool = false) async throws -> [MealTemplate] { + if !forceRefresh, !cachedTemplates.isEmpty { + return cachedTemplates + } + cachedTemplates = try await api.getTemplates() + return cachedTemplates + } + + func logTemplate(id: String, date: String) async throws { + try await api.logTemplate(id: id, date: date) + cachedEntries[date] = nil + } + + // MARK: - Invalidation + + func invalidateAll() { + cachedFoods = [] + cachedRecentFoods = [] + cachedTemplates = [] + cachedGoals = [:] + cachedEntries = [:] + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/FoodSearchViewModel.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/FoodSearchViewModel.swift new file mode 100644 index 0000000..41c9ee7 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/FoodSearchViewModel.swift @@ -0,0 +1,108 @@ +import Foundation + +@MainActor @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 + private var searchTask: Task? + + var displayedFoods: [FoodItem] { + if searchText.trimmingCharacters(in: .whitespaces).isEmpty { + return recentFoods + } + return searchResults + } + + var isShowingRecent: Bool { + searchText.trimmingCharacters(in: .whitespaces).isEmpty + } + + func loadRecent() async { + isLoadingRecent = true + do { + recentFoods = try await repo.recentFoods(forceRefresh: true) + } catch { + // Silent failure for recent foods + } + isLoadingRecent = false + } + + func search() { + let query = searchText.trimmingCharacters(in: .whitespaces) + + // Cancel previous search + searchTask?.cancel() + + 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 + } catch { + guard !Task.isCancelled else { return } + errorMessage = 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 1.44.16 AM/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift new file mode 100644 index 0000000..9721154 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift @@ -0,0 +1,23 @@ +import Foundation + +@MainActor @Observable +final class GoalsViewModel { + var goal: DailyGoal = .defaultGoal + var isLoading = true + var errorMessage: String? + + private let repo = FitnessRepository.shared + + func load() async { + isLoading = true + errorMessage = nil + + do { + goal = try await repo.goals(for: Date().apiDateString, forceRefresh: true) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/HistoryViewModel.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/HistoryViewModel.swift new file mode 100644 index 0000000..01ce99b --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/HistoryViewModel.swift @@ -0,0 +1,76 @@ +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 + } + + var groupedTemplates: [String: [MealTemplate]] { + Dictionary(grouping: templates, by: \.mealType) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/TodayViewModel.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/TodayViewModel.swift new file mode 100644 index 0000000..709a929 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/ViewModels/TodayViewModel.swift @@ -0,0 +1,111 @@ +import Foundation + +@MainActor @Observable +final class TodayViewModel { + var entries: [FoodEntry] = [] + var goal: DailyGoal = .defaultGoal + 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 dateString: String { + selectedDate.apiDateString + } + + var mealGroups: [MealGroup] { + MealType.allCases.map { meal in + MealGroup( + meal: meal, + entries: entries.filter { $0.mealType == 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 } + } + + var caloriesRemaining: Double { + max(goal.calories - totalCalories, 0) + } + + // 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 + } + + isLoading = false + } + + func goToNextDay() { + selectedDate = selectedDate.adding(days: 1) + Task { await load() } + } + + func goToPreviousDay() { + selectedDate = selectedDate.adding(days: -1) + Task { await load() } + } + + 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 1.44.16 AM/Platform/Features/Fitness/Views/AddFoodSheet.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/AddFoodSheet.swift new file mode 100644 index 0000000..0af6d64 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/AddFoodSheet.swift @@ -0,0 +1,239 @@ +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" + + 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) + } + Text("Add to \(mealType.displayName)") + .font(.body.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentWarm) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(isAdding || quantity <= 0) + } + .padding(20) + .background(Color.canvas) + .navigationTitle("Add Food") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundStyle(Color.text3) + } + } + .onAppear { + quantityText = formatQuantity(quantity) + } + } + } + + 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 { + VStack(spacing: 4) { + Text("\(Int(value))") + .font(.system(.title3, design: .rounded, weight: .bold)) + .foregroundStyle(color) + Text(label) + .font(.caption2) + .foregroundStyle(Color.text3) + } + } + + 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))" + } + return String(format: "%.1f", qty) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/EntryDetailView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/EntryDetailView.swift new file mode 100644 index 0000000..5a5a42c --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/EntryDetailView.swift @@ -0,0 +1,258 @@ +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)) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Header + entryHeader + + // Quantity editor + quantityEditor + + // Macros grid + macrosGrid + + // Details + detailsSection + + // Delete button + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + HStack(spacing: 8) { + Image(systemName: "trash") + Text("Delete Entry") + } + .font(.body.weight(.medium)) + .foregroundStyle(Color.error) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.error.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding(20) + } + .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)\"?") + } + } + } + + 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 { + VStack(spacing: 4) { + Text("\(Int(value))") + .font(.title3.weight(.bold)) + .foregroundStyle(color) + Text(label) + .font(.caption2) + .foregroundStyle(Color.text3) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(color.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + 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.unit)") + + if let method = entry.method, !method.isEmpty { + Divider() + detailRow("Method", value: method) + } + + if let note = entry.note, !note.isEmpty { + Divider() + detailRow("Note", value: note) + } + + if let loggedAt = entry.loggedAt, !loggedAt.isEmpty { + Divider() + detailRow("Logged", value: loggedAt) + } + } + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + + private func detailRow(_ label: String, value: String) -> some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundStyle(Color.text3) + Spacer() + Text(value) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text1) + .lineLimit(2) + .multilineTextAlignment(.trailing) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func formatQuantity(_ qty: Double) -> String { + if qty == qty.rounded() { + return "\(Int(qty))" + } + return String(format: "%.1f", qty) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/FitnessTabView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/FitnessTabView.swift new file mode 100644 index 0000000..00e350d --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/FitnessTabView.swift @@ -0,0 +1,75 @@ +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 history = "History" + case templates = "Templates" + case goals = "Goals" + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Custom segmented control + tabBar + + // Content + Group { + switch selectedTab { + case .today: + TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch) + case .history: + HistoryView() + case .templates: + TemplatesView(dateString: todayVM.dateString) { + Task { await todayVM.load() } + } + case .goals: + GoalsView() + } + } + } + .background(Color.canvas) + .navigationTitle("Fitness") + .navigationBarTitleDisplayMode(.large) + .sheet(isPresented: $showFoodSearch) { + FoodSearchView(date: todayVM.dateString) { + Task { await todayVM.load() } + } + } + } + } + + private var tabBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(FitnessTab.allCases, id: \.rawValue) { tab in + Button { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + } label: { + Text(tab.rawValue) + .font(.subheadline.weight(selectedTab == tab ? .semibold : .medium)) + .foregroundStyle(selectedTab == tab ? Color.surface : Color.text3) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + selectedTab == tab + ? Color.accentWarm + : Color.surfaceSecondary + ) + .clipShape(Capsule()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/FoodSearchView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/FoodSearchView.swift new file mode 100644 index 0000000..73cc258 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/FoodSearchView.swift @@ -0,0 +1,198 @@ +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 body: some View { + NavigationStack { + VStack(spacing: 0) { + // Search bar + searchBar + + // Content + if viewModel.isSearching || viewModel.isLoadingRecent { + LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...") + } else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent { + 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() + } + .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 { + ScrollView { + LazyVStack(spacing: 0) { + if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty { + sectionHeader("Recent Foods") + } + + ForEach(viewModel.displayedFoods) { food in + FoodItemRow(food: food) { + viewModel.selectFood(food) + } + Divider() + .padding(.leading, 60) + } + } + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.text4) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + } +} + +struct FoodItemRow: View { + let food: FoodItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + // Icon + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.accentWarmBg) + .frame(width: 40, height: 40) + + if let imageUrl = food.imageUrl, !imageUrl.isEmpty { + AsyncImage(url: URL(string: imageUrl)) { image in + image + .resizable() + .scaledToFill() + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } placeholder: { + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(Color.accentWarm) + } + } else { + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(Color.accentWarm) + } + } + + // Info + 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) + .lineLimit(1) + } + + Spacer() + + // Calories + VStack(alignment: .trailing, spacing: 2) { + Text("\(Int(food.caloriesPerBase))") + .font(.subheadline.weight(.bold)) + .foregroundStyle(Color.text1) + + Text("kcal") + .font(.caption2) + .foregroundStyle(Color.text4) + } + + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.text4) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/GoalsView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/GoalsView.swift new file mode 100644 index 0000000..32f4b2c --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/GoalsView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct GoalsView: View { + @State private var viewModel = 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 + ) + + 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) + } + } + .refreshable { + await viewModel.load() + } + .task { + await viewModel.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) + + Image(systemName: icon) + .font(.title3) + .foregroundStyle(color) + } + + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text2) + + Text("Daily target") + .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) + } + } + .padding(16) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.03), radius: 4, y: 2) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/HistoryView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/HistoryView.swift new file mode 100644 index 0000000..d0ccfea --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/HistoryView.swift @@ -0,0 +1,159 @@ +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 1.44.16 AM/Platform/Features/Fitness/Views/MealSectionView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/MealSectionView.swift new file mode 100644 index 0000000..fb4aa40 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/MealSectionView.swift @@ -0,0 +1,261 @@ +import SwiftUI + +struct MealSectionView: View { + let group: MealGroup + let isExpanded: Bool + let onToggle: () -> Void + let onDelete: (FoodEntry) -> Void + let onAddFood: () -> Void + + @State private var selectedEntry: FoodEntry? + + private var mealColor: Color { + Color.mealColor(for: group.meal.rawValue) + } + + 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) + + Text(group.meal.displayName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.text1) + + 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()) + } + + Spacer() + + if !group.entries.isEmpty { + Text("\(Int(group.totalCalories)) kcal") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.text3) + } + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.text4) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .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) + } + } + } + } + } + .background(Color.surface) + .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]) + } + } + + 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) { + Text(entry.foodName) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text1) + .lineLimit(1) + + Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)") + .font(.caption) + .foregroundStyle(Color.text3) + .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) + + HStack(spacing: 6) { + macroTag("P", value: entry.protein, color: .proteinColor) + macroTag("C", value: entry.carbs, color: .carbsColor) + macroTag("F", value: entry.fat, color: .fatColor) + } + } + } + .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) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/TemplatesView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/TemplatesView.swift new file mode 100644 index 0000000..31ada82 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/TemplatesView.swift @@ -0,0 +1,170 @@ +import SwiftUI + +struct TemplatesView: View { + let dateString: String + let onTemplateLogged: () -> Void + + @State private var viewModel = TemplatesViewModel() + @State private var confirmTemplate: MealTemplate? + + 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) + } + } + + // Ungrouped + let ungrouped = viewModel.templates.filter { template in + !MealType.allCases.map(\.rawValue).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) + } + } + .refreshable { + await viewModel.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)\"") { + Task { + await viewModel.logTemplate(template, date: dateString) { + onTemplateLogged() + } + } + } + Button("Cancel", role: .cancel) {} + } message: { template in + Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).") + } + } + + 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) { + Text(template.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text1) + .lineLimit(1) + + HStack(spacing: 8) { + Text("\(Int(template.calories)) kcal") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.caloriesColor) + + if let count = template.itemsCount { + Text("\(count) items") + .font(.caption) + .foregroundStyle(Color.text4) + } + + if let protein = template.protein { + Text("P\(Int(protein))") + .font(.caption) + .foregroundStyle(Color.proteinColor) + } + } + } + + Spacer() + + // Log button + Button(action: onLog) { + if isLogging { + ProgressView() + .controlSize(.small) + .tint(Color.accentWarm) + } else { + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(Color.accentWarm) + } + } + .disabled(isLogging) + } + .padding(14) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.03), radius: 4, y: 2) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/TodayView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/TodayView.swift new file mode 100644 index 0000000..eb2d039 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Fitness/Views/TodayView.swift @@ -0,0 +1,171 @@ +import SwiftUI + +struct TodayView: View { + @Bindable var viewModel: TodayViewModel + @Binding var showFoodSearch: Bool + + var body: some View { + ZStack(alignment: .bottomTrailing) { + ScrollView { + VStack(spacing: 16) { + // Date selector + dateSelector + + if viewModel.isLoading { + LoadingView(message: "Loading entries...") + .frame(height: 200) + } else { + // Macro summary card + macroSummaryCard + + // 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) + } + } + .padding(16) + } + .refreshable { + await viewModel.load() + } + + // Floating add button + addButton + } + .task { + await viewModel.load() + } + } + + // MARK: - Date Selector + + private var dateSelector: some View { + HStack(spacing: 0) { + Button { + viewModel.goToPreviousDay() + } label: { + Image(systemName: "chevron.left") + .font(.body.weight(.semibold)) + .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() + } label: { + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle( + viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm + ) + .frame(width: 44, height: 44) + } + .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) + } + + // MARK: - Macro Summary + + private var macroSummaryCard: some View { + VStack(spacing: 16) { + // Calories ring + HStack(spacing: 20) { + MacroRingLarge( + current: viewModel.totalCalories, + goal: viewModel.goal.calories, + color: .caloriesColor, + size: 100, + lineWidth: 9 + ) + + VStack(alignment: .leading, spacing: 10) { + macroRow("Protein", current: viewModel.totalProtein, goal: viewModel.goal.protein, color: .proteinColor) + macroRow("Carbs", current: viewModel.totalCarbs, goal: viewModel.goal.carbs, color: .carbsColor) + macroRow("Fat", current: viewModel.totalFat, goal: viewModel.goal.fat, color: .fatColor) + } + .frame(maxWidth: .infinity) + } + } + .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) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Home/HomeView.swift b/ios/Platform 1.44.16 AM/Platform/Features/Home/HomeView.swift new file mode 100644 index 0000000..261af0a --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Home/HomeView.swift @@ -0,0 +1,188 @@ +import SwiftUI + +struct HomeView: View { + @Environment(AuthManager.self) private var authManager + @State private var viewModel = HomeViewModel() + + var body: some View { + NavigationStack { + 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() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } label: { + Image(systemName: "person.circle.fill") + .font(.title3) + .foregroundStyle(Color.accentWarm) + } + } + } + .refreshable { + await viewModel.load() + } + .task { + await viewModel.load() + } + } + } + + private var caloriesSummaryCard: some View { + VStack(spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Today") + .font(.headline) + .foregroundStyle(Color.text1) + Text(Date().displayString) + .font(.subheadline) + .foregroundStyle(Color.text3) + } + Spacer() + Text("\(viewModel.entryCount) entries") + .font(.caption) + .foregroundStyle(Color.text4) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color.surfaceSecondary) + .clipShape(Capsule()) + } + + MacroRingLarge( + current: viewModel.totalCalories, + goal: viewModel.goal.calories, + color: .caloriesColor, + size: 140, + lineWidth: 12 + ) + + HStack(spacing: 0) { + macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal") + Spacer() + macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal") + Spacer() + macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal") + } + } + .padding(20) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 4) + } + + private var macrosCard: some View { + VStack(spacing: 14) { + Text("Macros") + .font(.headline) + .foregroundStyle(Color.text1) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 20) { + MacroRing( + current: viewModel.totalProtein, + goal: viewModel.goal.protein, + color: .proteinColor, + label: "Protein", + unit: "g", + size: 68 + ) + MacroRing( + current: viewModel.totalCarbs, + goal: viewModel.goal.carbs, + color: .carbsColor, + label: "Carbs", + unit: "g", + size: 68 + ) + MacroRing( + current: viewModel.totalFat, + goal: viewModel.goal.fat, + color: .fatColor, + label: "Fat", + unit: "g", + size: 68 + ) + } + .frame(maxWidth: .infinity) + } + .padding(20) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 4) + } + + private var quickActionsCard: some View { + VStack(spacing: 12) { + Text("Quick Actions") + .font(.headline) + .foregroundStyle(Color.text1) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 12) { + quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald) + quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor) + quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm) + } + } + .padding(20) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 4) + } + + private func macroStat(_ label: String, value: Int, unit: String) -> some View { + VStack(spacing: 2) { + Text("\(value)") + .font(.system(.title3, design: .rounded, weight: .bold)) + .foregroundStyle(Color.text1) + Text("\(label)") + .font(.caption2) + .foregroundStyle(Color.text4) + } + } + + private func quickActionButton(icon: String, label: String, color: Color) -> some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(color) + Text(label) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(Color.text2) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(color.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Features/Home/HomeViewModel.swift b/ios/Platform 1.44.16 AM/Platform/Features/Home/HomeViewModel.swift new file mode 100644 index 0000000..63d45d9 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Features/Home/HomeViewModel.swift @@ -0,0 +1,49 @@ +import Foundation + +@MainActor @Observable +final class HomeViewModel { + var todayEntries: [FoodEntry] = [] + var goal: DailyGoal = .defaultGoal + var isLoading = true + var errorMessage: String? + + private let repo = FitnessRepository.shared + + var totalCalories: Double { + todayEntries.reduce(0) { $0 + $1.calories } + } + + var totalProtein: Double { + todayEntries.reduce(0) { $0 + $1.protein } + } + + 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 { + 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 + } + + isLoading = false + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/PlatformApp.swift b/ios/Platform 1.44.16 AM/Platform/PlatformApp.swift new file mode 100644 index 0000000..8f3709b --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/PlatformApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct PlatformApp: App { + @State private var authManager = AuthManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(authManager) + } + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Shared/Components/LoadingView.swift b/ios/Platform 1.44.16 AM/Platform/Shared/Components/LoadingView.swift new file mode 100644 index 0000000..c5e82bc --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Shared/Components/LoadingView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct LoadingView: View { + var message: String = "Loading..." + + var body: some View { + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + .tint(Color.accentWarm) + Text(message) + .font(.subheadline) + .foregroundStyle(Color.text3) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.canvas) + } +} + +struct ErrorBanner: View { + let message: String + var onRetry: (() -> Void)? + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.error) + + Text(message) + .font(.subheadline) + .foregroundStyle(Color.text2) + + Spacer() + + if let onRetry { + Button("Retry") { + onRetry() + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentWarm) + } + } + .padding(12) + .background(Color.error.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +struct EmptyStateView: View { + let icon: String + let title: String + let subtitle: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundStyle(Color.text4) + + Text(title) + .font(.headline) + .foregroundStyle(Color.text2) + + Text(subtitle) + .font(.subheadline) + .foregroundStyle(Color.text3) + .multilineTextAlignment(.center) + } + .padding(40) + .frame(maxWidth: .infinity) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Shared/Components/MacroBar.swift b/ios/Platform 1.44.16 AM/Platform/Shared/Components/MacroBar.swift new file mode 100644 index 0000000..8c0de09 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Shared/Components/MacroBar.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct MacroBar: View { + let label: String + let current: Double + let goal: Double + let color: Color + var showGrams: Bool = true + + private var progress: Double { + guard goal > 0 else { return 0 } + return min(current / goal, 1.0) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(Color.text3) + 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) + } + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(color.opacity(0.12)) + .frame(height: 6) + + Capsule() + .fill(color) + .frame(width: geo.size.width * progress, height: 6) + .animation(.easeOut(duration: 0.5), value: progress) + } + } + .frame(height: 6) + } + } +} + +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 1.44.16 AM/Platform/Shared/Components/MacroRing.swift b/ios/Platform 1.44.16 AM/Platform/Shared/Components/MacroRing.swift new file mode 100644 index 0000000..ed42e23 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Shared/Components/MacroRing.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct MacroRing: View { + let current: 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 + + private var progress: Double { + guard goal > 0 else { return 0 } + return min(current / goal, 1.0) + } + + private var remaining: Double { + max(goal - current, 0) + } + + var body: some View { + ZStack { + Circle() + .stroke(color.opacity(0.12), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: progress) + .stroke( + color, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .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) + } + } + .frame(width: size, height: size) + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Shared/Extensions/Color+Extensions.swift b/ios/Platform 1.44.16 AM/Platform/Shared/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..339cd31 --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Shared/Extensions/Color+Extensions.swift @@ -0,0 +1,86 @@ +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") + + static let text1 = Color(hex: "18181B") + static let text2 = Color(hex: "3F3F46") + static let text3 = Color(hex: "71717A") + static let text4 = Color(hex: "A1A1AA") + + // Accent — warm amber/brown + static let accentWarm = Color(hex: "8B6914") + static let accentWarmBg = Color(hex: "FEF7E6") + + // Emerald accent from web + static let accentEmerald = Color(hex: "059669") + static let accentEmeraldBg = Color(hex: "ECFDF5") + + // Semantic + static let success = Color(hex: "059669") + static let error = Color(hex: "DC2626") + static let warning = Color(hex: "D97706") + + // 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") + + // 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") + + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } + + static func mealColor(for meal: String) -> Color { + switch meal.lowercased() { + case "breakfast": return .breakfast + case "lunch": return .lunch + case "dinner": return .dinner + case "snack": return .snack + default: return .text3 + } + } + + static func 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" + } + } +} diff --git a/ios/Platform 1.44.16 AM/Platform/Shared/Extensions/Date+Extensions.swift b/ios/Platform 1.44.16 AM/Platform/Shared/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..1345f0e --- /dev/null +++ b/ios/Platform 1.44.16 AM/Platform/Shared/Extensions/Date+Extensions.swift @@ -0,0 +1,58 @@ +import Foundation + +extension Date { + /// Format as yyyy-MM-dd for API calls + var apiDateString: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: self) + } + + /// Display format: "Mon, Apr 2" + var displayString: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEE, MMM d" + return formatter.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 + } +} diff --git a/ios/Platform/Platform/Config.swift b/ios/Platform/Platform/Config.swift index 2711eff..bdf83ae 100644 --- a/ios/Platform/Platform/Config.swift +++ b/ios/Platform/Platform/Config.swift @@ -1,5 +1,15 @@ import Foundation enum Config { - static let gatewayURL = "https://dash.quadjourney.com" + 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) + } } diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index dc02940..f80b2a2 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -6,10 +6,8 @@ struct ContentView: View { var body: some View { Group { if authManager.isCheckingAuth { - ProgressView("Loading...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.canvas) - } else if authManager.isLoggedIn { + LoadingView(message: "Checking session...") + } else if authManager.isAuthenticated { MainTabView() } else { LoginView() @@ -22,22 +20,18 @@ struct ContentView: View { } struct MainTabView: View { - @State private var selectedTab = 0 - var body: some View { - TabView(selection: $selectedTab) { + TabView { HomeView() .tabItem { Label("Home", systemImage: "house.fill") } - .tag(0) FitnessTabView() .tabItem { - Label("Fitness", systemImage: "figure.run") + Label("Fitness", systemImage: "flame.fill") } - .tag(1) } - .tint(.accentWarm) + .tint(Color.accentWarm) } } diff --git a/ios/Platform/Platform/Features/Auth/LoginView.swift b/ios/Platform/Platform/Features/Auth/LoginView.swift index 0f442f5..71d5e48 100644 --- a/ios/Platform/Platform/Features/Auth/LoginView.swift +++ b/ios/Platform/Platform/Features/Auth/LoginView.swift @@ -2,79 +2,157 @@ import SwiftUI struct LoginView: View { @Environment(AuthManager.self) private var authManager + @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 { - VStack(spacing: 24) { - Spacer() + ZStack { + Color.canvas + .ignoresSafeArea() - VStack(spacing: 8) { - Image(systemName: "square.grid.2x2.fill") - .font(.system(size: 48)) - .foregroundStyle(Color.accentWarm) - Text("Platform") - .font(.system(size: 32, weight: .bold, design: .rounded)) - .foregroundStyle(Color.textPrimary) - Text("Sign in to continue") - .font(.subheadline) - .foregroundStyle(Color.textSecondary) - } + ScrollView { + VStack(spacing: 32) { + Spacer() + .frame(height: 60) - VStack(spacing: 16) { - TextField("Username", text: $username) - .textFieldStyle(.plain) - .padding(14) - .background(Color.surfaceSecondary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .textContentType(.username) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + // Logo / Branding + VStack(spacing: 8) { + Image(systemName: "square.grid.2x2.fill") + .font(.system(size: 48)) + .foregroundStyle(Color.accentWarm) - SecureField("Password", text: $password) - .textFieldStyle(.plain) - .padding(14) - .background(Color.surfaceSecondary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .textContentType(.password) - } - .padding(.horizontal, 32) + Text("Platform") + .font(.largeTitle.weight(.bold)) + .foregroundStyle(Color.text1) - if let error = authManager.loginError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - - Button { - isLoading = true - Task { - await authManager.login(username: username, password: password) - isLoading = false - } - } label: { - Group { - if isLoading { - ProgressView() - .tint(.white) - } else { - Text("Sign In") - .fontWeight(.semibold) + Text("Sign in to your dashboard") + .font(.subheadline) + .foregroundStyle(Color.text3) } - } - .frame(maxWidth: .infinity) - .frame(height: 48) - } - .buttonStyle(.borderedProminent) - .tint(Color.accentWarm) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 32) - .disabled(username.isEmpty || password.isEmpty || isLoading) - Spacer() - Spacer() + // 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() + } + .padding(.horizontal, 28) + } + } + .onSubmit { + switch focusedField { + case .username: + focusedField = .password + case .password: + performLogin() + case .none: + break + } + } + } + + private var canSubmit: Bool { + !username.trimmingCharacters(in: .whitespaces).isEmpty + && !password.isEmpty + && !isLoading + } + + private func performLogin() { + guard canSubmit else { return } + isLoading = true + focusedField = nil + Task { + await authManager.login( + username: username.trimmingCharacters(in: .whitespaces), + password: password + ) + isLoading = false } - .background(Color.canvas.ignoresSafeArea()) } } diff --git a/ios/Platform/Platform/Features/Fitness/ViewModels/FoodSearchViewModel.swift b/ios/Platform/Platform/Features/Fitness/ViewModels/FoodSearchViewModel.swift index 3edcf22..41c9ee7 100644 --- a/ios/Platform/Platform/Features/Fitness/ViewModels/FoodSearchViewModel.swift +++ b/ios/Platform/Platform/Features/Fitness/ViewModels/FoodSearchViewModel.swift @@ -1,51 +1,108 @@ import Foundation -@Observable +@MainActor @Observable final class FoodSearchViewModel { - var query = "" - var results: [FoodItem] = [] + var searchText = "" + var searchResults: [FoodItem] = [] var recentFoods: [FoodItem] = [] - var allFoods: [FoodItem] = [] var isSearching = false - var isLoadingInitial = false + var isLoadingRecent = false + var errorMessage: String? - private let api = FitnessAPI() + // Add food sheet state + var selectedFood: FoodItem? + var showAddSheet = false + var addQuantity: Double = 1 + var addMealType: MealType = .guess() + var isAddingFood = false + + private let repo = FitnessRepository.shared private var searchTask: Task? - func loadInitial() async { - isLoadingInitial = true - do { - async let r = api.getRecentFoods() - async let a = api.getFoods() - recentFoods = try await r - allFoods = try await a - } catch { - // Silently fail + var displayedFoods: [FoodItem] { + if searchText.trimmingCharacters(in: .whitespaces).isEmpty { + return recentFoods } - isLoadingInitial = false + return searchResults + } + + var isShowingRecent: Bool { + searchText.trimmingCharacters(in: .whitespaces).isEmpty + } + + func loadRecent() async { + isLoadingRecent = true + do { + recentFoods = try await repo.recentFoods(forceRefresh: true) + } catch { + // Silent failure for recent foods + } + isLoadingRecent = false } func search() { + let query = searchText.trimmingCharacters(in: .whitespaces) + + // Cancel previous search searchTask?.cancel() - let q = query.trimmingCharacters(in: .whitespaces) - guard q.count >= 2 else { - results = [] + + 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 items = try await api.searchFoods(query: q) - if !Task.isCancelled { - results = items - isSearching = false - } + let results = try await repo.searchFoods(query: query) + guard !Task.isCancelled else { return } + searchResults = results } catch { - if !Task.isCancelled { - isSearching = false - } + guard !Task.isCancelled else { return } + errorMessage = 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 b20deb9..9721154 100644 --- a/ios/Platform/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift +++ b/ios/Platform/Platform/Features/Fitness/ViewModels/GoalsViewModel.swift @@ -1,54 +1,23 @@ import Foundation -@Observable +@MainActor @Observable final class GoalsViewModel { - var calories: String = "" - var protein: String = "" - var carbs: String = "" - var fat: String = "" - var sugar: String = "" - var fiber: String = "" - var isLoading = false - var isSaving = false - var error: String? - var saved = false + var goal: DailyGoal = .defaultGoal + var isLoading = true + var errorMessage: String? - private let api = FitnessAPI() + private let repo = FitnessRepository.shared - func load(date: String) async { + func load() async { isLoading = true + errorMessage = nil + do { - let goals = try await api.getGoals(date: date) - calories = String(Int(goals.calories)) - protein = String(Int(goals.protein)) - carbs = String(Int(goals.carbs)) - fat = String(Int(goals.fat)) - sugar = String(Int(goals.sugar)) - fiber = String(Int(goals.fiber)) + goal = try await repo.goals(for: Date().apiDateString, forceRefresh: true) } catch { - self.error = error.localizedDescription + errorMessage = error.localizedDescription } + isLoading = false } - - func save() async { - isSaving = true - error = nil - saved = false - let req = UpdateGoalsRequest( - calories: Double(calories) ?? 2000, - protein: Double(protein) ?? 150, - carbs: Double(carbs) ?? 250, - fat: Double(fat) ?? 65, - sugar: Double(sugar) ?? 50, - fiber: Double(fiber) ?? 30 - ) - do { - _ = try await api.updateGoals(req) - saved = true - } catch { - self.error = error.localizedDescription - } - isSaving = false - } } diff --git a/ios/Platform/Platform/Features/Fitness/ViewModels/HistoryViewModel.swift b/ios/Platform/Platform/Features/Fitness/ViewModels/HistoryViewModel.swift new file mode 100644 index 0000000..01ce99b --- /dev/null +++ b/ios/Platform/Platform/Features/Fitness/ViewModels/HistoryViewModel.swift @@ -0,0 +1,76 @@ +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 api.logTemplate(id: template.id, date: date) - logSuccess = "Logged \(template.name)" - // Refresh the repository - await FitnessRepository.shared.loadDay(date: date) + try await repo.logTemplate(id: template.id, date: date) + loggedTemplateId = nil + onComplete() } catch { - self.error = error.localizedDescription + errorMessage = "Failed to log template: \(error.localizedDescription)" + loggedTemplateId = nil } + + isLogging = false } - var groupedByMeal: [MealType: [MealTemplate]] { - Dictionary(grouping: templates, by: { $0.mealType }) + var groupedTemplates: [String: [MealTemplate]] { + Dictionary(grouping: templates, by: \.mealType) } } diff --git a/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift b/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift index f1ec6b1..709a929 100644 --- a/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift +++ b/ios/Platform/Platform/Features/Fitness/ViewModels/TodayViewModel.swift @@ -1,36 +1,111 @@ import Foundation -@Observable +@MainActor @Observable final class TodayViewModel { + var entries: [FoodEntry] = [] + var goal: DailyGoal = .defaultGoal var selectedDate: Date = Date() - let repository = FitnessRepository.shared + var isLoading = true + var errorMessage: String? + var expandedMeals: Set = Set(MealType.allCases.map(\.rawValue)) + + private let repo = FitnessRepository.shared + + // MARK: - Computed Properties var dateString: String { selectedDate.apiDateString } - var displayDate: String { - if selectedDate.isToday { - return "Today" + var mealGroups: [MealGroup] { + MealType.allCases.map { meal in + MealGroup( + meal: meal, + entries: entries.filter { $0.mealType == meal.rawValue } + ) } - return selectedDate.displayString } + 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 caloriesRemaining: Double { + max(goal.calories - totalCalories, 0) + } + + // MARK: - Actions + func load() async { - await repository.loadDay(date: dateString) + 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 + } + + isLoading = false } - func previousDay() { - selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate + func goToNextDay() { + selectedDate = selectedDate.adding(days: 1) Task { await load() } } - func nextDay() { - selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate + func goToPreviousDay() { + selectedDate = selectedDate.adding(days: -1) Task { await load() } } + 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 { - await repository.deleteEntry(id: entry.id) + // 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 aa3d34b..0af6d64 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift @@ -2,170 +2,238 @@ import SwiftUI struct AddFoodSheet: View { let food: FoodItem - let mealType: MealType - let dateString: String - let onAdded: () -> Void + @Binding var quantity: Double + @Binding var mealType: MealType + let isAdding: Bool + let onAdd: () -> Void @Environment(\.dismiss) private var dismiss - @State private var quantity: Double = 1.0 - @State private var selectedMeal: MealType - @State private var isAdding = false - - init(food: FoodItem, mealType: MealType, dateString: String, onAdded: @escaping () -> Void) { - self.food = food - self.mealType = mealType - self.dateString = dateString - self.onAdded = onAdded - _selectedMeal = State(initialValue: mealType) - } - - private var scaledCalories: Double { food.calories * quantity } - private var scaledProtein: Double { food.protein * quantity } - private var scaledCarbs: Double { food.carbs * quantity } - private var scaledFat: Double { food.fat * quantity } + @State private var quantityText: String = "1" var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 20) { - // Food header - VStack(spacing: 8) { - if let img = food.imageFilename { - AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in - image.resizable().aspectRatio(contentMode: .fill) - } placeholder: { - Color.surfaceSecondary - } - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } + VStack(spacing: 24) { + // Food info header + foodHeader - Text(food.name) - .font(.title3.bold()) - .foregroundStyle(Color.textPrimary) + // Quantity input + quantitySection - if let serving = food.servingSize { - Text(serving) - .font(.caption) - .foregroundStyle(Color.textSecondary) + // Meal picker + mealPickerSection + + // Macro preview + macroPreview + + Spacer() + + // Add button + Button(action: onAdd) { + HStack(spacing: 8) { + if isAdding { + ProgressView() + .controlSize(.small) + .tint(.white) } + Text("Add to \(mealType.displayName)") + .font(.body.weight(.semibold)) } - - // Quantity - VStack(spacing: 8) { - Text("Quantity") - .font(.subheadline.bold()) - .foregroundStyle(Color.textSecondary) - HStack(spacing: 16) { - Button { if quantity > 0.5 { quantity -= 0.5 } } label: { - Image(systemName: "minus.circle.fill") - .font(.title2) - .foregroundStyle(Color.accentWarm) - } - Text(String(format: "%.1f", quantity)) - .font(.title2.bold()) - .frame(minWidth: 60) - Button { quantity += 0.5 } label: { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundStyle(Color.accentWarm) - } - } - } - - // Meal picker - VStack(spacing: 8) { - Text("Meal") - .font(.subheadline.bold()) - .foregroundStyle(Color.textSecondary) - HStack(spacing: 8) { - ForEach(MealType.allCases) { meal in - Button { - selectedMeal = meal - } label: { - Text(meal.displayName) - .font(.caption.bold()) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(selectedMeal == meal ? meal.color.opacity(0.2) : Color.surfaceSecondary) - .foregroundStyle(selectedMeal == meal ? meal.color : Color.textSecondary) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - } - } - } - - // Nutrition preview - VStack(spacing: 8) { - nutritionRow("Calories", "\(Int(scaledCalories))") - nutritionRow("Protein", "\(Int(scaledProtein))g") - nutritionRow("Carbs", "\(Int(scaledCarbs))g") - nutritionRow("Fat", "\(Int(scaledFat))g") - } - .padding(16) - .background(Color.surfaceSecondary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - - // Add button - Button { - isAdding = true - Task { - let req = CreateEntryRequest( - foodId: food.id, - foodName: food.name, - mealType: selectedMeal.rawValue, - quantity: quantity, - entryDate: dateString, - calories: food.calories, - protein: food.protein, - carbs: food.carbs, - fat: food.fat, - sugar: food.sugar, - fiber: food.fiber - ) - await FitnessRepository.shared.addEntry(req) - isAdding = false - dismiss() - onAdded() - } - } label: { - Group { - if isAdding { - ProgressView().tint(.white) - } else { - Text("Add") - .fontWeight(.semibold) - } - } - .frame(maxWidth: .infinity) - .frame(height: 48) - } - .buttonStyle(.borderedProminent) - .tint(Color.accentWarm) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentWarm) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 14)) } - .padding(20) + .disabled(isAdding || quantity <= 0) } + .padding(20) .background(Color.canvas) .navigationTitle("Add Food") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundStyle(Color.text3) + } + } + .onAppear { + quantityText = formatQuantity(quantity) + } + } + } + + 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 func nutritionRow(_ label: String, _ value: String) -> some View { - HStack { - Text(label) - .font(.subheadline) - .foregroundStyle(Color.textSecondary) - Spacer() - Text(value) - .font(.subheadline.bold()) - .foregroundStyle(Color.textPrimary) + 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 { + VStack(spacing: 4) { + Text("\(Int(value))") + .font(.system(.title3, design: .rounded, weight: .bold)) + .foregroundStyle(color) + Text(label) + .font(.caption2) + .foregroundStyle(Color.text3) + } + } + + 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))" + } + 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 c91e23d..5a5a42c 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/EntryDetailView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/EntryDetailView.swift @@ -2,159 +2,257 @@ import SwiftUI struct EntryDetailView: View { let entry: FoodEntry - let dateString: String + let onDelete: () -> Void + let onUpdateQuantity: (Double) -> Void + @Environment(\.dismiss) private var dismiss - @State private var quantity: Double - @State private var isDeleting = false - @State private var isSaving = false + @State private var editQuantity: String + @State private var showDeleteConfirm = false - init(entry: FoodEntry, dateString: String) { + init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) { self.entry = entry - self.dateString = dateString - _quantity = State(initialValue: entry.quantity) + self.onDelete = onDelete + self.onUpdateQuantity = onUpdateQuantity + _editQuantity = State(initialValue: entry.quantity == entry.quantity.rounded() ? "\(Int(entry.quantity))" : String(format: "%.1f", entry.quantity)) } - private var scaledCalories: Double { entry.calories * quantity } - private var scaledProtein: Double { entry.protein * quantity } - private var scaledCarbs: Double { entry.carbs * quantity } - private var scaledFat: Double { entry.fat * quantity } - private var scaledSugar: Double? { entry.sugar.map { $0 * quantity } } - private var scaledFiber: Double? { entry.fiber.map { $0 * quantity } } - var body: some View { NavigationStack { ScrollView { VStack(spacing: 20) { - // Food name - Text(entry.foodName) - .font(.title2.bold()) - .foregroundStyle(Color.textPrimary) - - // Meal badge - HStack { - Image(systemName: entry.mealType.icon) - Text(entry.mealType.displayName) - } - .font(.caption.bold()) - .foregroundStyle(entry.mealType.color) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(entry.mealType.color.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + // Header + entryHeader // Quantity editor - VStack(spacing: 8) { - Text("Quantity") - .font(.subheadline.bold()) - .foregroundStyle(Color.textSecondary) - HStack(spacing: 16) { - Button { if quantity > 0.5 { quantity -= 0.5 } } label: { - Image(systemName: "minus.circle.fill") - .font(.title2) - .foregroundStyle(Color.accentWarm) - } - Text(String(format: "%.1f", quantity)) - .font(.title2.bold()) - .frame(minWidth: 60) - Button { quantity += 0.5 } label: { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundStyle(Color.accentWarm) - } - } - } + quantityEditor - if quantity != entry.quantity { - Button { - isSaving = true - Task { - _ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity) - isSaving = false - dismiss() - } - } label: { - Group { - if isSaving { - ProgressView().tint(.white) - } else { - Text("Update Quantity") - .fontWeight(.semibold) - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) + // Macros grid + macrosGrid + + // Details + detailsSection + + // Delete button + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + HStack(spacing: 8) { + Image(systemName: "trash") + Text("Delete Entry") } - .buttonStyle(.borderedProminent) - .tint(Color.accentWarm) + .font(.body.weight(.medium)) + .foregroundStyle(Color.error) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.error.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 12)) } - - // Nutrition grid - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - nutritionCell("Calories", "\(Int(scaledCalories))", "kcal") - nutritionCell("Protein", "\(Int(scaledProtein))", "g") - nutritionCell("Carbs", "\(Int(scaledCarbs))", "g") - nutritionCell("Fat", "\(Int(scaledFat))", "g") - if let sugar = scaledSugar { - nutritionCell("Sugar", "\(Int(sugar))", "g") - } - if let fiber = scaledFiber { - nutritionCell("Fiber", "\(Int(fiber))", "g") - } - } - .padding(16) - .background(Color.surface) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - // Delete - Button(role: .destructive) { - isDeleting = true - Task { - await FitnessRepository.shared.deleteEntry(id: entry.id) - isDeleting = false - dismiss() - } - } label: { - HStack { - if isDeleting { - ProgressView().tint(.red) - } else { - Image(systemName: "trash") - Text("Delete Entry") - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.bordered) - .tint(.red) - .clipShape(RoundedRectangle(cornerRadius: 12)) } .padding(20) } .background(Color.canvas) - .navigationTitle("Entry Detail") + .navigationTitle("Entry Details") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { dismiss() } + 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)\"?") + } } } - private func nutritionCell(_ label: String, _ value: String, _ unit: String) -> some View { + 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 { VStack(spacing: 4) { - Text(value) - .font(.title3.bold()) - .foregroundStyle(Color.textPrimary) - Text("\(label) (\(unit))") - .font(.caption) - .foregroundStyle(Color.textSecondary) + Text("\(Int(value))") + .font(.title3.weight(.bold)) + .foregroundStyle(color) + Text(label) + .font(.caption2) + .foregroundStyle(Color.text3) } .frame(maxWidth: .infinity) - .padding(12) - .background(Color.surfaceSecondary) + .padding(.vertical, 10) + .background(color.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 10)) } + + 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.unit)") + + if let method = entry.method, !method.isEmpty { + Divider() + detailRow("Method", value: method) + } + + if let note = entry.note, !note.isEmpty { + Divider() + detailRow("Note", value: note) + } + + if let loggedAt = entry.loggedAt, !loggedAt.isEmpty { + Divider() + detailRow("Logged", value: loggedAt) + } + } + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + + private func detailRow(_ label: String, value: String) -> some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundStyle(Color.text3) + Spacer() + Text(value) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text1) + .lineLimit(2) + .multilineTextAlignment(.trailing) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func formatQuantity(_ qty: Double) -> String { + if qty == qty.rounded() { + return "\(Int(qty))" + } + return String(format: "%.1f", qty) + } } diff --git a/ios/Platform/Platform/Features/Fitness/Views/FitnessTabView.swift b/ios/Platform/Platform/Features/Fitness/Views/FitnessTabView.swift index d39aa4f..00e350d 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/FitnessTabView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/FitnessTabView.swift @@ -1,83 +1,75 @@ import SwiftUI -enum FitnessTab: String, CaseIterable { - case today = "Today" - case templates = "Templates" - case goals = "Goals" - case foods = "Foods" -} - struct FitnessTabView: View { @State private var selectedTab: FitnessTab = .today - @State private var showAssistant = false @State private var todayVM = TodayViewModel() + @State private var showFoodSearch = false + + enum FitnessTab: String, CaseIterable { + case today = "Today" + case history = "History" + case templates = "Templates" + case goals = "Goals" + } var body: some View { NavigationStack { - ZStack(alignment: .bottomTrailing) { - VStack(spacing: 0) { - // Tab bar - HStack(spacing: 24) { - ForEach(FitnessTab.allCases, id: \.self) { tab in - Button { - withAnimation(.easeInOut(duration: 0.2)) { - selectedTab = tab - } - } label: { - Text(tab.rawValue) - .font(.subheadline) - .fontWeight(selectedTab == tab ? .bold : .medium) - .foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary) - .padding(.bottom, 8) - .overlay(alignment: .bottom) { - if selectedTab == tab { - Rectangle() - .fill(Color.accentWarm) - .frame(height: 2) - } - } - } + VStack(spacing: 0) { + // Custom segmented control + tabBar + + // Content + Group { + switch selectedTab { + case .today: + TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch) + case .history: + HistoryView() + case .templates: + TemplatesView(dateString: todayVM.dateString) { + Task { await todayVM.load() } } + case .goals: + GoalsView() } - .padding(.top, 60) - .padding(.horizontal, 24) - - // Content - TabView(selection: $selectedTab) { - TodayView(viewModel: todayVM) - .tag(FitnessTab.today) - TemplatesView(dateString: todayVM.dateString) - .tag(FitnessTab.templates) - GoalsView(dateString: todayVM.dateString) - .tag(FitnessTab.goals) - FoodLibraryView(dateString: todayVM.dateString) - .tag(FitnessTab.foods) - } - .tabViewStyle(.page(indexDisplayMode: .never)) } - - // Floating + button - Button { - showAssistant = true - } label: { - Image(systemName: "plus") - .font(.title2.bold()) - .foregroundStyle(.white) - .frame(width: 56, height: 56) - .background(Color.accentWarm) - .clipShape(Circle()) - .shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4) - } - .padding(.trailing, 20) - .padding(.bottom, 20) } - .background(Color.canvas.ignoresSafeArea()) - .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: $showAssistant) { - AssistantChatView(entryDate: todayVM.dateString) { + .background(Color.canvas) + .navigationTitle("Fitness") + .navigationBarTitleDisplayMode(.large) + .sheet(isPresented: $showFoodSearch) { + FoodSearchView(date: todayVM.dateString) { Task { await todayVM.load() } } } } } + + private var tabBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(FitnessTab.allCases, id: \.rawValue) { tab in + Button { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + } label: { + Text(tab.rawValue) + .font(.subheadline.weight(selectedTab == tab ? .semibold : .medium)) + .foregroundStyle(selectedTab == tab ? Color.surface : Color.text3) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + selectedTab == tab + ? Color.accentWarm + : Color.surfaceSecondary + ) + .clipShape(Capsule()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } } diff --git a/ios/Platform/Platform/Features/Fitness/Views/FoodSearchView.swift b/ios/Platform/Platform/Features/Fitness/Views/FoodSearchView.swift index 7608474..73cc258 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/FoodSearchView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/FoodSearchView.swift @@ -1,119 +1,198 @@ import SwiftUI struct FoodSearchView: View { - let mealType: MealType - let dateString: String + let date: String + let onFoodAdded: () -> Void + @Environment(\.dismiss) private var dismiss - @State private var vm = FoodSearchViewModel() - @State private var selectedFood: FoodItem? + @State private var viewModel = FoodSearchViewModel() + @FocusState private var searchFocused: Bool var body: some View { NavigationStack { VStack(spacing: 0) { // Search bar - HStack { - Image(systemName: "magnifyingglass") - .foregroundStyle(Color.textTertiary) - TextField("Search foods...", text: $vm.query) - .textFieldStyle(.plain) - .autocorrectionDisabled() - .onChange(of: vm.query) { _, _ in - vm.search() - } - if !vm.query.isEmpty { - Button { vm.query = ""; vm.results = [] } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Color.textTertiary) - } - } - } - .padding(12) - .background(Color.surfaceSecondary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) - .padding(.top, 8) + searchBar - if vm.isSearching || vm.isLoadingInitial { - LoadingView() - } else if !vm.query.isEmpty && vm.query.count >= 2 { - // Search results - List { - Section { - ForEach(vm.results) { food in - foodRow(food) - } - } header: { - Text("\(vm.results.count) results") - } - } - .listStyle(.plain) + // Content + if viewModel.isSearching || viewModel.isLoadingRecent { + LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...") + } else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent { + EmptyStateView( + icon: "magnifyingglass", + title: "No results", + subtitle: "Try a different search term" + ) } else { - // Default: recent + all - List { - if !vm.recentFoods.isEmpty { - Section("Recent") { - ForEach(vm.recentFoods) { food in - foodRow(food) - } - } - } - if !vm.allFoods.isEmpty { - Section("All Foods") { - ForEach(vm.allFoods) { food in - foodRow(food) - } - } - } - } - .listStyle(.plain) + foodList } } .background(Color.canvas) .navigationTitle("Add Food") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundStyle(Color.text3) } } - } - .task { - await vm.loadInitial() - } - .sheet(item: $selectedFood) { food in - AddFoodSheet(food: food, mealType: mealType, dateString: dateString) { - dismiss() + .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 func foodRow(_ food: FoodItem) -> some View { - Button { - selectedFood = food - } label: { - HStack { - if let img = food.imageFilename { - AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in - image.resizable().aspectRatio(contentMode: .fill) - } placeholder: { - Color.surfaceSecondary - } - .frame(width: 40, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 8)) + 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() } - VStack(alignment: .leading, spacing: 2) { - Text(food.name) - .font(.subheadline) - .foregroundStyle(Color.textPrimary) - Text("\(Int(food.calories)) cal") - .font(.caption) - .foregroundStyle(Color.textSecondary) + if !viewModel.searchText.isEmpty { + Button { + viewModel.searchText = "" + viewModel.searchResults = [] + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.text4) } - Spacer() - Image(systemName: "plus.circle") - .foregroundStyle(Color.accentWarm) } } + .padding(12) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var foodList: some View { + ScrollView { + LazyVStack(spacing: 0) { + if viewModel.isShowingRecent && !viewModel.recentFoods.isEmpty { + sectionHeader("Recent Foods") + } + + ForEach(viewModel.displayedFoods) { food in + FoodItemRow(food: food) { + viewModel.selectFood(food) + } + Divider() + .padding(.leading, 60) + } + } + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.text4) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + } +} + +struct FoodItemRow: View { + let food: FoodItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + // Icon + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.accentWarmBg) + .frame(width: 40, height: 40) + + if let imageUrl = food.imageUrl, !imageUrl.isEmpty { + AsyncImage(url: URL(string: imageUrl)) { image in + image + .resizable() + .scaledToFill() + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } placeholder: { + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(Color.accentWarm) + } + } else { + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(Color.accentWarm) + } + } + + // Info + 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) + .lineLimit(1) + } + + Spacer() + + // Calories + VStack(alignment: .trailing, spacing: 2) { + Text("\(Int(food.caloriesPerBase))") + .font(.subheadline.weight(.bold)) + .foregroundStyle(Color.text1) + + Text("kcal") + .font(.caption2) + .foregroundStyle(Color.text4) + } + + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.text4) + } + .padding(.horizontal, 16) + .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 8f20401..32f4b2c 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift @@ -1,85 +1,139 @@ import SwiftUI struct GoalsView: View { - let dateString: String - @State private var vm = GoalsViewModel() + @State private var viewModel = GoalsViewModel() var body: some View { ScrollView { - VStack(spacing: 16) { - if vm.isLoading { - LoadingView() - } else { - VStack(spacing: 12) { - goalField("Calories", value: $vm.calories, unit: "kcal") - goalField("Protein", value: $vm.protein, unit: "g") - goalField("Carbs", value: $vm.carbs, unit: "g") - goalField("Fat", value: $vm.fat, unit: "g") - goalField("Sugar", value: $vm.sugar, unit: "g") - goalField("Fiber", value: $vm.fiber, unit: "g") - } - .padding(16) - .background(Color.surface) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.horizontal, 16) + 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) - Button { - Task { await vm.save() } - } label: { - Group { - if vm.isSaving { - ProgressView().tint(.white) - } else { - Text("Save Goals") - .fontWeight(.semibold) - } - } - .frame(maxWidth: .infinity) - .frame(height: 48) - } - .buttonStyle(.borderedProminent) - .tint(Color.accentWarm) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) - .disabled(vm.isSaving) + // Goals cards + goalCard( + label: "Calories", + value: viewModel.goal.calories, + unit: "kcal", + icon: "flame.fill", + color: .caloriesColor + ) - if vm.saved { - Text("Goals saved!") + 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.emerald) - } - - if let err = vm.error { - ErrorBanner(message: err) + .foregroundStyle(Color.text3) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) } - - Spacer(minLength: 80) + .padding(16) } - .padding(.top, 8) + + if let error = viewModel.errorMessage { + ErrorBanner(message: error) { + Task { await viewModel.load() } + } + .padding(16) + } + } + .refreshable { + await viewModel.load() } .task { - await vm.load(date: dateString) + await viewModel.load() } } - private func goalField(_ label: String, value: Binding, unit: String) -> some View { - HStack { - Text(label) - .font(.subheadline) - .foregroundStyle(Color.textSecondary) - .frame(width: 80, alignment: .leading) - TextField("0", text: value) - .keyboardType(.numberPad) - .textFieldStyle(.plain) - .font(.subheadline.bold()) - .padding(10) - .background(Color.surfaceSecondary) - .clipShape(RoundedRectangle(cornerRadius: 8)) - Text(unit) - .font(.caption) - .foregroundStyle(Color.textTertiary) - .frame(width: 32) + 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) + + Image(systemName: icon) + .font(.title3) + .foregroundStyle(color) + } + + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text2) + + Text("Daily target") + .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) + } } + .padding(16) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.03), radius: 4, y: 2) } } diff --git a/ios/Platform/Platform/Features/Fitness/Views/HistoryView.swift b/ios/Platform/Platform/Features/Fitness/Views/HistoryView.swift new file mode 100644 index 0000000..d0ccfea --- /dev/null +++ b/ios/Platform/Platform/Features/Fitness/Views/HistoryView.swift @@ -0,0 +1,159 @@ +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 8acec1c..fb4aa40 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/MealSectionView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/MealSectionView.swift @@ -1,131 +1,261 @@ import SwiftUI struct MealSectionView: View { - let meal: MealType - let entries: [FoodEntry] - let mealCalories: Double + let group: MealGroup + let isExpanded: Bool + let onToggle: () -> Void let onDelete: (FoodEntry) -> Void - let dateString: String + let onAddFood: () -> Void - @State private var isExpanded = true - @State private var showFoodSearch = false @State private var selectedEntry: FoodEntry? + private var mealColor: Color { + Color.mealColor(for: group.meal.rawValue) + } + var body: some View { VStack(spacing: 0) { // Header - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - } label: { - HStack(spacing: 0) { - // Colored accent bar - RoundedRectangle(cornerRadius: 2) - .fill(meal.color) - .frame(width: 4, height: 44) - .padding(.trailing, 12) - - Image(systemName: meal.icon) - .font(.headline) - .foregroundStyle(meal.color) + Button(action: onToggle) { + HStack(spacing: 10) { + Image(systemName: group.meal.icon) + .font(.body) + .foregroundStyle(mealColor) .frame(width: 28) - VStack(alignment: .leading, spacing: 2) { - Text(meal.displayName) - .font(.headline) - .foregroundStyle(Color.textPrimary) - Text("\(entries.count) item\(entries.count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(Color.textSecondary) + Text(group.meal.displayName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.text1) + + 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()) } - .padding(.leading, 8) Spacer() - Text("\(Int(mealCalories)) cal") - .font(.subheadline.bold()) - .foregroundStyle(meal.color) + if !group.entries.isEmpty { + Text("\(Int(group.totalCalories)) kcal") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.text3) + } - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(Color.textTertiary) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - .padding(.leading, 8) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.text4) } .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.vertical, 14) } + .buttonStyle(.plain) // Entries - if isExpanded && !entries.isEmpty { - VStack(spacing: 0) { - ForEach(entries) { entry in - Button { - selectedEntry = entry - } label: { - entryRow(entry) - } - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - onDelete(entry) - } label: { - Label("Delete", systemImage: "trash") - } - } - } - } - .padding(.leading, 40) - } - - // Add button if isExpanded { - Button { - showFoodSearch = true - } label: { - HStack { - Image(systemName: "plus.circle.fill") - .foregroundStyle(meal.color.opacity(0.6)) - Text("Add food") - .font(.subheadline) - .foregroundStyle(Color.textSecondary) + 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) + } } - .padding(.vertical, 8) - .padding(.leading, 44) - .frame(maxWidth: .infinity, alignment: .leading) } } } - .background(meal.color.opacity(0.03)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) - .sheet(isPresented: $showFoodSearch) { - FoodSearchView(mealType: meal, dateString: dateString) - } + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.03), radius: 4, y: 2) .sheet(item: $selectedEntry) { entry in - EntryDetailView(entry: entry, dateString: dateString) + EntryDetailView( + entry: entry, + onDelete: { onDelete(entry) }, + onUpdateQuantity: { _ in } + ) + .presentationDetents([.large]) } } - private func entryRow(_ entry: FoodEntry) -> some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(entry.foodName) + private var emptyMealView: some View { + Button(action: onAddFood) { + HStack(spacing: 8) { + Image(systemName: "plus.circle") + .foregroundStyle(mealColor) + Text("Add food") .font(.subheadline) - .foregroundStyle(Color.textPrimary) - .lineLimit(1) - if entry.quantity != 1 { - Text("x\(String(format: "%.1f", entry.quantity))") - .font(.caption) - .foregroundStyle(Color.textSecondary) - } + .foregroundStyle(Color.text3) } - Spacer() - Text("\(Int(entry.calories * entry.quantity))") - .font(.subheadline) - .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) } - .padding(.vertical, 6) - .padding(.horizontal, 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) { + Text(entry.foodName) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text1) + .lineLimit(1) + + Text(entry.servingDescription ?? "\(formatQuantity(entry.quantity)) \(entry.unit)") + .font(.caption) + .foregroundStyle(Color.text3) + .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) + + HStack(spacing: 6) { + macroTag("P", value: entry.protein, color: .proteinColor) + macroTag("C", value: entry.carbs, color: .carbsColor) + macroTag("F", value: entry.fat, color: .fatColor) + } + } + } + .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) } } diff --git a/ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift b/ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift index aa24ed3..31ada82 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift @@ -2,102 +2,169 @@ import SwiftUI struct TemplatesView: View { let dateString: String - @State private var vm = TemplatesViewModel() - @State private var templateToLog: MealTemplate? - @State private var showConfirm = false + let onTemplateLogged: () -> Void + + @State private var viewModel = TemplatesViewModel() + @State private var confirmTemplate: MealTemplate? var body: some View { ScrollView { - VStack(spacing: 16) { - if vm.isLoading { - LoadingView() - } else if vm.templates.isEmpty { - EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app") - } else { + 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 = vm.groupedByMeal[meal] ?? [] + let templates = viewModel.groupedTemplates[meal.rawValue] ?? [] if !templates.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: meal.icon) - .foregroundStyle(meal.color) - Text(meal.displayName) - .font(.headline) - .foregroundStyle(Color.textPrimary) - } - .padding(.horizontal, 16) - - ForEach(templates) { template in - templateCard(template) - } - } + templateSection(meal: meal, templates: templates) } } - } - if let msg = vm.logSuccess { - Text(msg) - .font(.caption) - .foregroundStyle(Color.emerald) - .padding(.top, 4) + // Ungrouped + let ungrouped = viewModel.templates.filter { template in + !MealType.allCases.map(\.rawValue).contains(template.mealType) + } + if !ungrouped.isEmpty { + templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped) + } } - - if let err = vm.error { - ErrorBanner(message: err) - } - - Spacer(minLength: 80) + .padding(16) } - .padding(.top, 8) + + if let error = viewModel.errorMessage { + ErrorBanner(message: error) { + Task { await viewModel.load() } + } + .padding(16) + } + } + .refreshable { + await viewModel.load() } .task { - await vm.load() + await viewModel.load() } - .alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in - Button("Log") { - Task { await vm.logTemplate(template, date: dateString) } + .confirmationDialog( + "Log Template", + isPresented: Binding( + get: { confirmTemplate != nil }, + set: { if !$0 { confirmTemplate = nil } } + ), + presenting: confirmTemplate + ) { template in + Button("Log \"\(template.name)\"") { + Task { + await viewModel.logTemplate(template, date: dateString) { + onTemplateLogged() + } + } } Button("Cancel", role: .cancel) {} } message: { template in - Text("Add \(template.name) to today's log?") + Text("This will add all items from \"\(template.name)\" (\(Int(template.calories)) kcal) to \(dateString).") } } - private func templateCard(_ template: MealTemplate) -> some View { - HStack { + 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) { Text(template.name) - .font(.subheadline.bold()) - .foregroundStyle(Color.textPrimary) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.text1) + .lineLimit(1) + HStack(spacing: 8) { - if let cal = template.totalCalories { - Text("\(Int(cal)) cal") + 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 items = template.itemCount { - Text("\(items) items") + + if let protein = template.protein { + Text("P\(Int(protein))") + .font(.caption) + .foregroundStyle(Color.proteinColor) } } - .font(.caption) - .foregroundStyle(Color.textSecondary) } + Spacer() - Button { - templateToLog = template - showConfirm = true - } label: { - Text("Log meal") - .font(.caption.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.accentWarm) - .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Log button + Button(action: onLog) { + if isLogging { + ProgressView() + .controlSize(.small) + .tint(Color.accentWarm) + } else { + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(Color.accentWarm) + } } + .disabled(isLogging) } .padding(14) .background(Color.surface) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.03), radius: 4, y: 1) - .padding(.horizontal, 16) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.03), 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 4541921..eb2d039 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift @@ -2,95 +2,170 @@ import SwiftUI struct TodayView: View { @Bindable var viewModel: TodayViewModel - @State private var showFoodSearch = false + @Binding var showFoodSearch: Bool var body: some View { - ScrollView { - VStack(spacing: 16) { - // Date selector - HStack { - Button { viewModel.previousDay() } label: { - Image(systemName: "chevron.left") - .font(.headline) - .foregroundStyle(Color.accentWarm) - } - Spacer() - Text(viewModel.displayDate) - .font(.headline) - .foregroundStyle(Color.textPrimary) - Spacer() - Button { viewModel.nextDay() } label: { - Image(systemName: "chevron.right") - .font(.headline) - .foregroundStyle(Color.accentWarm) - } - } - .padding(.horizontal, 20) - .padding(.top, 8) - .gesture( - DragGesture(minimumDistance: 50) - .onEnded { value in - if value.translation.width > 0 { - viewModel.previousDay() - } else { - viewModel.nextDay() - } + ZStack(alignment: .bottomTrailing) { + ScrollView { + VStack(spacing: 16) { + // Date selector + dateSelector + + if viewModel.isLoading { + LoadingView(message: "Loading entries...") + .frame(height: 200) + } else { + // Macro summary card + macroSummaryCard + + // 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 + } + ) } - ) - // Macro summary card - if !viewModel.repository.isLoading { - macroSummaryCard + // Bottom spacing for FAB + Spacer() + .frame(height: 80) + } + + if let error = viewModel.errorMessage { + ErrorBanner(message: error) { + Task { await viewModel.load() } + } + .padding(.horizontal, 4) + } } - - // Error - if let error = viewModel.repository.error { - ErrorBanner(message: error) { await viewModel.load() } - } - - // Meal sections - ForEach(MealType.allCases) { meal in - MealSectionView( - meal: meal, - entries: viewModel.repository.entriesForMeal(meal), - mealCalories: viewModel.repository.mealCalories(meal), - onDelete: { entry in - Task { await viewModel.deleteEntry(entry) } - }, - dateString: viewModel.dateString - ) - } - - Spacer(minLength: 80) + .padding(16) } - } - .refreshable { - await viewModel.load() + .refreshable { + await viewModel.load() + } + + // Floating add button + addButton } .task { await viewModel.load() } } - private var macroSummaryCard: some View { - let repo = viewModel.repository - let goals = repo.goals + // MARK: - Date Selector - return VStack(spacing: 12) { - HStack(spacing: 20) { - LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories) + private var dateSelector: some View { + HStack(spacing: 0) { + Button { + viewModel.goToPreviousDay() + } label: { + Image(systemName: "chevron.left") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.accentWarm) + .frame(width: 44, height: 44) + } - VStack(spacing: 8) { - MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue) - MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange) - MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple) + 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() + } label: { + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle( + viewModel.selectedDate.isToday ? Color.text4 : Color.accentWarm + ) + .frame(width: 44, height: 44) + } + .disabled(viewModel.selectedDate.isToday) } - .padding(16) + .padding(.horizontal, 4) + .padding(.vertical, 4) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.03), radius: 4, y: 2) + } + + // MARK: - Macro Summary + + private var macroSummaryCard: some View { + VStack(spacing: 16) { + // Calories ring + HStack(spacing: 20) { + MacroRingLarge( + current: viewModel.totalCalories, + goal: viewModel.goal.calories, + color: .caloriesColor, + size: 100, + lineWidth: 9 + ) + + VStack(alignment: .leading, spacing: 10) { + macroRow("Protein", current: viewModel.totalProtein, goal: viewModel.goal.protein, color: .proteinColor) + macroRow("Carbs", current: viewModel.totalCarbs, goal: viewModel.goal.carbs, color: .carbsColor) + macroRow("Fat", current: viewModel.totalFat, goal: viewModel.goal.fat, color: .fatColor) + } + .frame(maxWidth: .infinity) + } + } + .padding(20) .background(Color.surface) .clipShape(RoundedRectangle(cornerRadius: 16)) - .shadow(color: .black.opacity(0.04), radius: 8, y: 2) - .padding(.horizontal, 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) } } diff --git a/ios/Platform/Platform/Features/Home/HomeView.swift b/ios/Platform/Platform/Features/Home/HomeView.swift index cd399c0..261af0a 100644 --- a/ios/Platform/Platform/Features/Home/HomeView.swift +++ b/ios/Platform/Platform/Features/Home/HomeView.swift @@ -1,161 +1,188 @@ import SwiftUI -import PhotosUI struct HomeView: View { @Environment(AuthManager.self) private var authManager - @State private var vm = HomeViewModel() - @State private var showAssistant = false - @State private var showProfileMenu = false - @State private var selectedPhoto: PhotosPickerItem? + @State private var viewModel = HomeViewModel() var body: some View { NavigationStack { - ZStack(alignment: .bottomTrailing) { - // Background - if let bg = vm.backgroundImage { - Image(uiImage: bg) - .resizable() - .aspectRatio(contentMode: .fill) - .ignoresSafeArea() - .overlay(Color.black.opacity(0.2).ignoresSafeArea()) - } - else { - Color.canvas.ignoresSafeArea() - } + ScrollView { + VStack(spacing: 20) { + if viewModel.isLoading { + LoadingView(message: "Loading dashboard...") + .frame(height: 300) + } else { + // Quick Stats Card + caloriesSummaryCard - ScrollView { - VStack(spacing: 16) { - // Top bar - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Dashboard") - .font(.title2.bold()) - .foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary) - if let name = authManager.user?.displayName ?? authManager.user?.username { - Text("Welcome, \(name)") - .font(.subheadline) - .foregroundStyle(vm.backgroundImage != nil ? .white.opacity(0.8) : Color.textSecondary) - } - } - Spacer() - Menu { - PhotosPicker(selection: $selectedPhoto, matching: .images) { - Label("Change Background", systemImage: "photo") - } - if vm.backgroundImage != nil { - Button(role: .destructive) { - vm.removeBackground() - } label: { - Label("Remove Background", systemImage: "trash") - } - } - Divider() - Button(role: .destructive) { - authManager.logout() - } label: { - Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") - } - } label: { - Image(systemName: "person.circle.fill") - .font(.title2) - .foregroundStyle(vm.backgroundImage != nil ? .white : Color.accentWarm) - } + // Macros Card + macrosCard + + // Quick Actions + quickActionsCard + } + + if let error = viewModel.errorMessage { + ErrorBanner(message: error) { + Task { await viewModel.load() } } - .padding(.horizontal, 20) - .padding(.top, 60) - - // Widget grid - LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { - calorieWidget - quickStatsWidget - } - .padding(.horizontal, 16) - - Spacer(minLength: 100) } } - - // Floating + button - Button { - showAssistant = true - } label: { - Image(systemName: "plus") - .font(.title2.bold()) - .foregroundStyle(.white) - .frame(width: 56, height: 56) - .background(Color.accentWarm) - .clipShape(Circle()) - .shadow(color: Color.accentWarm.opacity(0.3), radius: 8, y: 4) - } - .padding(.trailing, 20) - .padding(.bottom, 20) + .padding(16) } - .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: $showAssistant) { - AssistantChatView(entryDate: Date().apiDateString) { - Task { await vm.loadData() } - } - } - .onChange(of: selectedPhoto) { _, newValue in - guard let item = newValue else { return } - Task { - if let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data) { - vm.setBackground(image) + .background(Color.canvas) + .navigationTitle("Dashboard") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(role: .destructive) { + authManager.logout() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } label: { + Image(systemName: "person.circle.fill") + .font(.title3) + .foregroundStyle(Color.accentWarm) } - selectedPhoto = nil } } + .refreshable { + await viewModel.load() + } .task { - await vm.loadData() + await viewModel.load() } } } - private var calorieWidget: some View { - let hasBg = vm.backgroundImage != nil - return VStack(spacing: 8) { - LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal) - Text("Calories") - .font(.caption.bold()) - .foregroundStyle(hasBg ? .white : Color.textSecondary) - } - .padding(16) - .frame(maxWidth: .infinity) - .background { - if hasBg { - RoundedRectangle(cornerRadius: 16) - .fill(.ultraThinMaterial) - } else { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - .shadow(color: .black.opacity(0.04), radius: 8, y: 2) + private var caloriesSummaryCard: some View { + VStack(spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Today") + .font(.headline) + .foregroundStyle(Color.text1) + Text(Date().displayString) + .font(.subheadline) + .foregroundStyle(Color.text3) + } + Spacer() + Text("\(viewModel.entryCount) entries") + .font(.caption) + .foregroundStyle(Color.text4) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color.surfaceSecondary) + .clipShape(Capsule()) + } + + MacroRingLarge( + current: viewModel.totalCalories, + goal: viewModel.goal.calories, + color: .caloriesColor, + size: 140, + lineWidth: 12 + ) + + HStack(spacing: 0) { + macroStat("Eaten", value: Int(viewModel.totalCalories), unit: "kcal") + Spacer() + macroStat("Remaining", value: Int(max(viewModel.goal.calories - viewModel.totalCalories, 0)), unit: "kcal") + Spacer() + macroStat("Goal", value: Int(viewModel.goal.calories), unit: "kcal") } } + .padding(20) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 4) } - private var quickStatsWidget: some View { - let hasBg = vm.backgroundImage != nil - let repo = FitnessRepository.shared - return VStack(alignment: .leading, spacing: 10) { + private var macrosCard: some View { + VStack(spacing: 14) { Text("Macros") - .font(.caption.bold()) - .foregroundStyle(hasBg ? .white : Color.textSecondary) - MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true) - MacroBar(label: "Carbs", value: repo.totalCarbs, goal: repo.goals.carbs, color: .orange, compact: true) - MacroBar(label: "Fat", value: repo.totalFat, goal: repo.goals.fat, color: .purple, compact: true) + .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(16) - .frame(maxWidth: .infinity) - .background { - if hasBg { - RoundedRectangle(cornerRadius: 16) - .fill(.ultraThinMaterial) - } else { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - .shadow(color: .black.opacity(0.04), radius: 8, y: 2) + .padding(20) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 4) + } + + private var quickActionsCard: some View { + VStack(spacing: 12) { + Text("Quick Actions") + .font(.headline) + .foregroundStyle(Color.text1) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 12) { + quickActionButton(icon: "plus.circle.fill", label: "Log Food", color: .accentEmerald) + quickActionButton(icon: "doc.text.fill", label: "Templates", color: .carbsColor) + quickActionButton(icon: "clock.fill", label: "History", color: .accentWarm) } } + .padding(20) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 4) + } + + private func macroStat(_ label: String, value: Int, unit: String) -> some View { + VStack(spacing: 2) { + Text("\(value)") + .font(.system(.title3, design: .rounded, weight: .bold)) + .foregroundStyle(Color.text1) + Text("\(label)") + .font(.caption2) + .foregroundStyle(Color.text4) + } + } + + private func quickActionButton(icon: String, label: String, color: Color) -> some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(color) + Text(label) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(Color.text2) + } + .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 b7bfbbe..63d45d9 100644 --- a/ios/Platform/Platform/Features/Home/HomeViewModel.swift +++ b/ios/Platform/Platform/Features/Home/HomeViewModel.swift @@ -1,54 +1,49 @@ -import SwiftUI -import PhotosUI +import Foundation -@Observable +@MainActor @Observable final class HomeViewModel { - var backgroundImage: UIImage? - var caloriesConsumed: Double = 0 - var caloriesGoal: Double = 2000 - var isLoading = false + var todayEntries: [FoodEntry] = [] + var goal: DailyGoal = .defaultGoal + var isLoading = true + var errorMessage: String? - private let bgKey = "homeBackgroundImage" private let repo = FitnessRepository.shared - init() { - loadBackgroundFromDefaults() + var totalCalories: Double { + todayEntries.reduce(0) { $0 + $1.calories } } - func loadData() async { + var totalProtein: Double { + todayEntries.reduce(0) { $0 + $1.protein } + } + + 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 { isLoading = true + errorMessage = nil let today = Date().apiDateString - await repo.loadDay(date: today) - caloriesConsumed = repo.totalCalories - caloriesGoal = repo.goals.calories + + 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 + } + isLoading = false } - - func setBackground(_ image: UIImage) { - // Resize to max 1200px - let maxDim: CGFloat = 1200 - let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0) - let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - image.draw(in: CGRect(origin: .zero, size: newSize)) - let resized = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - if let resized, let data = resized.jpegData(compressionQuality: 0.8) { - UserDefaults.standard.set(data, forKey: bgKey) - backgroundImage = resized - } - } - - func removeBackground() { - UserDefaults.standard.removeObject(forKey: bgKey) - backgroundImage = nil - } - - private func loadBackgroundFromDefaults() { - if let data = UserDefaults.standard.data(forKey: bgKey), - let img = UIImage(data: data) { - backgroundImage = img - } - } } diff --git a/ios/Platform/Platform/Shared/Components/LoadingView.swift b/ios/Platform/Platform/Shared/Components/LoadingView.swift index 59f461f..c5e82bc 100644 --- a/ios/Platform/Platform/Shared/Components/LoadingView.swift +++ b/ios/Platform/Platform/Shared/Components/LoadingView.swift @@ -4,41 +4,45 @@ struct LoadingView: View { var message: String = "Loading..." var body: some View { - VStack(spacing: 12) { + VStack(spacing: 16) { ProgressView() - .tint(.accentWarm) + .controlSize(.large) + .tint(Color.accentWarm) Text(message) .font(.subheadline) - .foregroundStyle(Color.textSecondary) + .foregroundStyle(Color.text3) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.canvas) } } struct ErrorBanner: View { let message: String - var retry: (() async -> Void)? + var onRetry: (() -> Void)? var body: some View { - HStack(spacing: 8) { + HStack(spacing: 12) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(Color.error) + Text(message) .font(.subheadline) - .foregroundStyle(Color.textPrimary) + .foregroundStyle(Color.text2) + Spacer() - if let retry = retry { + + if let onRetry { Button("Retry") { - Task { await retry() } + onRetry() } - .font(.subheadline.bold()) + .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.accentWarm) } } .padding(12) - .background(Color.orange.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) + .background(Color.error.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 12)) } } @@ -51,13 +55,15 @@ struct EmptyStateView: View { VStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 40)) - .foregroundStyle(Color.textTertiary) + .foregroundStyle(Color.text4) + Text(title) .font(.headline) - .foregroundStyle(Color.textPrimary) + .foregroundStyle(Color.text2) + Text(subtitle) .font(.subheadline) - .foregroundStyle(Color.textSecondary) + .foregroundStyle(Color.text3) .multilineTextAlignment(.center) } .padding(40) diff --git a/ios/Platform/Platform/Shared/Components/MacroBar.swift b/ios/Platform/Platform/Shared/Components/MacroBar.swift index 93e5cce..8c0de09 100644 --- a/ios/Platform/Platform/Shared/Components/MacroBar.swift +++ b/ios/Platform/Platform/Shared/Components/MacroBar.swift @@ -2,43 +2,74 @@ import SwiftUI struct MacroBar: View { let label: String - let value: Double + let current: Double let goal: Double let color: Color - var compact: Bool = false + var showGrams: Bool = true private var progress: Double { guard goal > 0 else { return 0 } - return min(max(value / goal, 0), 1) + return min(current / goal, 1.0) } var body: some View { - VStack(alignment: .leading, spacing: compact ? 2 : 4) { + VStack(alignment: .leading, spacing: 4) { HStack { Text(label) - .font(compact ? .caption2 : .caption) + .font(.caption) .fontWeight(.medium) - .foregroundStyle(Color.textSecondary) + .foregroundStyle(Color.text3) Spacer() - Text("\(Int(value))/\(Int(goal))g") - .font(compact ? .caption2 : .caption) - .fontWeight(.semibold) - .foregroundStyle(Color.textPrimary) + 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) + } } GeometryReader { geo in ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: compact ? 2 : 3) - .fill(color.opacity(0.15)) - .frame(height: compact ? 4 : 6) + Capsule() + .fill(color.opacity(0.12)) + .frame(height: 6) - RoundedRectangle(cornerRadius: compact ? 2 : 3) + Capsule() .fill(color) - .frame(width: max(0, geo.size.width * progress), height: compact ? 4 : 6) - .animation(.easeInOut(duration: 0.5), value: progress) + .frame(width: geo.size.width * progress, height: 6) + .animation(.easeOut(duration: 0.5), value: progress) } } - .frame(height: compact ? 4 : 6) + .frame(height: 6) } } } + +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 c5add01..ed42e23 100644 --- a/ios/Platform/Platform/Shared/Components/MacroRing.swift +++ b/ios/Platform/Platform/Shared/Components/MacroRing.swift @@ -1,83 +1,96 @@ import SwiftUI struct MacroRing: View { - let consumed: Double + let current: Double let goal: Double let color: Color - var size: CGFloat = 80 - var lineWidth: CGFloat = 8 - var showLabel: Bool = true - var labelFontSize: CGFloat = 14 + 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(max(consumed / goal, 0), 1) + 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 + + private var progress: Double { + guard goal > 0 else { return 0 } + return min(current / goal, 1.0) + } + + private var remaining: Double { + max(goal - current, 0) } var body: some View { ZStack { Circle() - .stroke(color.opacity(0.15), lineWidth: lineWidth) + .stroke(color.opacity(0.12), lineWidth: lineWidth) Circle() .trim(from: 0, to: progress) - .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .stroke( + color, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.6), value: progress) + .animation(.easeOut(duration: 0.5), value: progress) - if showLabel { - VStack(spacing: 0) { - Text("\(Int(consumed))") - .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(Color.textPrimary) - if goal > 0 { - Text("/ \(Int(goal))") - .font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded)) - .foregroundStyle(Color.textSecondary) - } - } + 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) } } .frame(width: size, height: size) } } - -struct LargeCalorieRing: View { - let consumed: Double - let goal: Double - - private var remaining: Int { - max(0, Int(goal) - Int(consumed)) - } - - private var progress: Double { - guard goal > 0 else { return 0 } - return min(max(consumed / goal, 0), 1) - } - - var body: some View { - ZStack { - Circle() - .stroke(Color.emerald.opacity(0.15), lineWidth: 14) - - Circle() - .trim(from: 0, to: progress) - .stroke( - Color.emerald, - style: StrokeStyle(lineWidth: 14, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.8), value: progress) - - VStack(spacing: 2) { - Text("\(Int(consumed))") - .font(.system(size: 28, weight: .bold, design: .rounded)) - .foregroundStyle(Color.textPrimary) - Text("\(remaining) left") - .font(.system(size: 12, weight: .medium, design: .rounded)) - .foregroundStyle(Color.textSecondary) - } - } - .frame(width: 120, height: 120) - } -} diff --git a/ios/Platform/Platform/Shared/Extensions/Color+Extensions.swift b/ios/Platform/Platform/Shared/Extensions/Color+Extensions.swift index 2c88f0b..339cd31 100644 --- a/ios/Platform/Platform/Shared/Extensions/Color+Extensions.swift +++ b/ios/Platform/Platform/Shared/Extensions/Color+Extensions.swift @@ -1,12 +1,53 @@ 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") + + static let text1 = Color(hex: "18181B") + static let text2 = Color(hex: "3F3F46") + static let text3 = Color(hex: "71717A") + static let text4 = Color(hex: "A1A1AA") + + // Accent — warm amber/brown + static let accentWarm = Color(hex: "8B6914") + static let accentWarmBg = Color(hex: "FEF7E6") + + // Emerald accent from web + static let accentEmerald = Color(hex: "059669") + static let accentEmeraldBg = Color(hex: "ECFDF5") + + // Semantic + static let success = Color(hex: "059669") + static let error = Color(hex: "DC2626") + static let warning = Color(hex: "D97706") + + // 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") + + // 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") + 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: @@ -23,25 +64,23 @@ extension Color { ) } - // Core palette - static let canvas = Color(hex: "F5EFE6") - static let surface = Color(hex: "FFFFFF") - static let surfaceSecondary = Color(hex: "FAF7F2") - static let accentWarm = Color(hex: "8B6914") - static let accentWarmLight = Color(hex: "D4A843") - static let emerald = Color(hex: "059669") - static let textPrimary = Color(hex: "1C1917") - static let textSecondary = Color(hex: "78716C") - static let textTertiary = Color(hex: "A8A29E") - static let border = Color(hex: "E7E5E4") + 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 + } + } - // Meal colors - static let breakfastColor = Color(hex: "F59E0B") - static let lunchColor = Color(hex: "059669") - static let dinnerColor = Color(hex: "8B5CF6") - static let snackColor = Color(hex: "EC4899") - - // Chat - static let userBubble = Color(hex: "8B6914").opacity(0.15) - static let assistantBubble = Color(hex: "F5F5F4") + 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" + } + } } diff --git a/ios/Platform/Platform/Shared/Extensions/Date+Extensions.swift b/ios/Platform/Platform/Shared/Extensions/Date+Extensions.swift index 2fdd79d..1345f0e 100644 --- a/ios/Platform/Platform/Shared/Extensions/Date+Extensions.swift +++ b/ios/Platform/Platform/Shared/Extensions/Date+Extensions.swift @@ -1,6 +1,7 @@ import Foundation extension Date { + /// Format as yyyy-MM-dd for API calls var apiDateString: String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" @@ -8,15 +9,24 @@ extension Date { return formatter.string(from: self) } + /// Display format: "Mon, Apr 2" var displayString: String { let formatter = DateFormatter() - formatter.dateFormat = "EEEE, MMM d" + formatter.dateFormat = "EEE, MMM d" return formatter.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, yyyy" + formatter.dateFormat = "MMM d" return formatter.string(from: self) } @@ -24,10 +34,25 @@ extension Date { 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 + } }