restore: original UI views from first build, keep fixed models/API
All checks were successful
Security Checks / dependency-audit (push) Successful in 12s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 01:54:46 -05:00
parent e852e98812
commit fdb8aeba8a
61 changed files with 6652 additions and 1178 deletions

View File

@@ -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 = "<group>"; };
B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B10003 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
B10004 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
B10009 /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessModels.swift; sourceTree = "<group>"; };
B10010 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessAPI.swift; sourceTree = "<group>"; };
B10011 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessRepository.swift; sourceTree = "<group>"; };
B10012 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessTabView.swift; sourceTree = "<group>"; };
B10013 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
B10014 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSectionView.swift; sourceTree = "<group>"; };
B10015 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchView.swift; sourceTree = "<group>"; };
B10016 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFoodSheet.swift; sourceTree = "<group>"; };
B10017 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
B10018 /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesView.swift; sourceTree = "<group>"; };
B10019 /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsView.swift; sourceTree = "<group>"; };
B10020 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryDetailView.swift; sourceTree = "<group>"; };
B10021 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = "<group>"; };
B10022 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchViewModel.swift; sourceTree = "<group>"; };
B10023 /* HistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewModel.swift; sourceTree = "<group>"; };
B10024 /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatesViewModel.swift; sourceTree = "<group>"; };
B10025 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsViewModel.swift; sourceTree = "<group>"; };
B10026 /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroRing.swift; sourceTree = "<group>"; };
B10027 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroBar.swift; sourceTree = "<group>"; };
B10028 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
B10029 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
B10031 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* 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 = "<group>";
};
G10001 /* Platform */ = {
isa = PBXGroup;
children = (
B10001 /* PlatformApp.swift */,
B10002 /* ContentView.swift */,
B10003 /* Config.swift */,
B10031 /* Assets.xcassets */,
G10002 /* Core */,
G10003 /* Features */,
G10004 /* Shared */,
);
path = Platform;
sourceTree = "<group>";
};
G10002 /* Core */ = {
isa = PBXGroup;
children = (
B10004 /* APIClient.swift */,
B10005 /* AuthManager.swift */,
);
path = Core;
sourceTree = "<group>";
};
G10003 /* Features */ = {
isa = PBXGroup;
children = (
G10010 /* Auth */,
G10011 /* Home */,
G10012 /* Fitness */,
);
path = Features;
sourceTree = "<group>";
};
G10004 /* Shared */ = {
isa = PBXGroup;
children = (
G10005 /* Components */,
G10006 /* Extensions */,
);
path = Shared;
sourceTree = "<group>";
};
G10005 /* Components */ = {
isa = PBXGroup;
children = (
B10026 /* MacroRing.swift */,
B10027 /* MacroBar.swift */,
B10028 /* LoadingView.swift */,
);
path = Components;
sourceTree = "<group>";
};
G10006 /* Extensions */ = {
isa = PBXGroup;
children = (
B10029 /* Date+Extensions.swift */,
B10030 /* Color+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
G10010 /* Auth */ = {
isa = PBXGroup;
children = (
B10006 /* LoginView.swift */,
);
path = Auth;
sourceTree = "<group>";
};
G10011 /* Home */ = {
isa = PBXGroup;
children = (
B10007 /* HomeView.swift */,
B10008 /* HomeViewModel.swift */,
);
path = Home;
sourceTree = "<group>";
};
G10012 /* Fitness */ = {
isa = PBXGroup;
children = (
G10013 /* Models */,
G10014 /* API */,
G10015 /* Repository */,
G10016 /* Views */,
G10017 /* ViewModels */,
);
path = Fitness;
sourceTree = "<group>";
};
G10013 /* Models */ = {
isa = PBXGroup;
children = (
B10009 /* FitnessModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
G10014 /* API */ = {
isa = PBXGroup;
children = (
B10010 /* FitnessAPI.swift */,
);
path = API;
sourceTree = "<group>";
};
G10015 /* Repository */ = {
isa = PBXGroup;
children = (
B10011 /* FitnessRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
G10016 /* Views */ = {
isa = PBXGroup;
children = (
B10012 /* FitnessTabView.swift */,
B10013 /* TodayView.swift */,
B10014 /* MealSectionView.swift */,
B10015 /* FoodSearchView.swift */,
B10016 /* AddFoodSheet.swift */,
B10017 /* HistoryView.swift */,
B10018 /* TemplatesView.swift */,
B10019 /* GoalsView.swift */,
B10020 /* EntryDetailView.swift */,
);
path = Views;
sourceTree = "<group>";
};
G10017 /* ViewModels */ = {
isa = PBXGroup;
children = (
B10021 /* TodayViewModel.swift */,
B10022 /* FoodSearchViewModel.swift */,
B10023 /* HistoryViewModel.swift */,
B10024 /* TemplatesViewModel.swift */,
B10025 /* GoalsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
G10099 /* Products */ = {
isa = PBXGroup;
children = (
B10000 /* Platform.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
T10001 /* Platform */ = {
isa = PBXNativeTarget;
buildConfigurationList = CL10002 /* Build configuration list for PBXNativeTarget "Platform" */;
buildPhases = (
S10001 /* Sources */,
C10001 /* Frameworks */,
R10001 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Platform;
productName = Platform;
productReference = B10000 /* Platform.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
P10001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
TargetAttributes = {
T10001 = {
CreatedOnToolsVersion = 15.4;
};
};
};
buildConfigurationList = CL10001 /* Build configuration list for PBXProject "Platform" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = G10000;
productRefGroup = G10099 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
T10001 /* Platform */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
R10001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10031 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
S10001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10001 /* PlatformApp.swift in Sources */,
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 */;
}

View File

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

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Platform.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -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
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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<T: Decodable>(
_ 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<T: Decodable>(
_ path: String,
queryItems: [URLQueryItem]? = nil
) async throws -> T {
try await request("GET", path: path, queryItems: queryItems)
}
func post<T: Decodable>(
_ path: String,
body: (any Encodable)? = nil,
queryItems: [URLQueryItem]? = nil
) async throws -> T {
try await request("POST", path: path, body: body, queryItems: queryItems)
}
func patch<T: Decodable>(
_ 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)
}
}
}

View File

@@ -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")
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 }
}
}

View File

@@ -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 = [:]
}
}

View File

@@ -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<Void, Never>?
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
}
}

View File

@@ -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
}
}

View File

@@ -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..<numberOfDays {
let date = Date().adding(days: -i)
let dateString = date.apiDateString
let entries = try await repo.entries(for: dateString, forceRefresh: i == 0)
let goal = try await repo.goals(for: dateString)
results.append(HistoryDay(
date: date,
dateString: dateString,
entries: entries,
goal: goal
))
}
days = results
} catch {
errorMessage = error.localizedDescription
days = results // Show what we have
}
isLoading = false
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
@MainActor @Observable
final class TemplatesViewModel {
var templates: [MealTemplate] = []
var isLoading = true
var errorMessage: String?
var isLogging = false
var loggedTemplateId: String?
private let repo = FitnessRepository.shared
func load() async {
isLoading = true
errorMessage = nil
do {
templates = try await repo.templates(forceRefresh: true)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func logTemplate(_ template: MealTemplate, date: String, onComplete: @escaping () -> Void) async {
isLogging = true
loggedTemplateId = template.id
do {
try await repo.logTemplate(id: template.id, date: date)
loggedTemplateId = nil
onComplete()
} catch {
errorMessage = "Failed to log template: \(error.localizedDescription)"
loggedTemplateId = nil
}
isLogging = false
}
var groupedTemplates: [String: [MealTemplate]] {
Dictionary(grouping: templates, by: \.mealType)
}
}

View File

@@ -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<String> = 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"
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<Content: View>: View {
let onDelete: () -> Void
@ViewBuilder let content: () -> Content
@State private var offset: CGFloat = 0
@State private var showDelete = false
private let deleteThreshold: CGFloat = -80
private let deleteWidth: CGFloat = 80
var body: some View {
ZStack(alignment: .trailing) {
// Delete background
HStack {
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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct PlatformApp: App {
@State private var authManager = AuthManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(authManager)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}
}

View File

@@ -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
}
}

View File

@@ -1,5 +1,15 @@
import Foundation import Foundation
enum Config { 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)
}
} }

View File

@@ -6,10 +6,8 @@ struct ContentView: View {
var body: some View { var body: some View {
Group { Group {
if authManager.isCheckingAuth { if authManager.isCheckingAuth {
ProgressView("Loading...") LoadingView(message: "Checking session...")
.frame(maxWidth: .infinity, maxHeight: .infinity) } else if authManager.isAuthenticated {
.background(Color.canvas)
} else if authManager.isLoggedIn {
MainTabView() MainTabView()
} else { } else {
LoginView() LoginView()
@@ -22,22 +20,18 @@ struct ContentView: View {
} }
struct MainTabView: View { struct MainTabView: View {
@State private var selectedTab = 0
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView {
HomeView() HomeView()
.tabItem { .tabItem {
Label("Home", systemImage: "house.fill") Label("Home", systemImage: "house.fill")
} }
.tag(0)
FitnessTabView() FitnessTabView()
.tabItem { .tabItem {
Label("Fitness", systemImage: "figure.run") Label("Fitness", systemImage: "flame.fill")
} }
.tag(1)
} }
.tint(.accentWarm) .tint(Color.accentWarm)
} }
} }

View File

@@ -2,79 +2,157 @@ import SwiftUI
struct LoginView: View { struct LoginView: View {
@Environment(AuthManager.self) private var authManager @Environment(AuthManager.self) private var authManager
@State private var username = "" @State private var username = ""
@State private var password = "" @State private var password = ""
@State private var isLoading = false @State private var isLoading = false
@FocusState private var focusedField: Field?
private enum Field: Hashable {
case username, password
}
var body: some View { var body: some View {
VStack(spacing: 24) { ZStack {
Spacer() Color.canvas
.ignoresSafeArea()
VStack(spacing: 8) { ScrollView {
Image(systemName: "square.grid.2x2.fill") VStack(spacing: 32) {
.font(.system(size: 48)) Spacer()
.foregroundStyle(Color.accentWarm) .frame(height: 60)
Text("Platform")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
Text("Sign in to continue")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
VStack(spacing: 16) { // Logo / Branding
TextField("Username", text: $username) VStack(spacing: 8) {
.textFieldStyle(.plain) Image(systemName: "square.grid.2x2.fill")
.padding(14) .font(.system(size: 48))
.background(Color.surfaceSecondary) .foregroundStyle(Color.accentWarm)
.clipShape(RoundedRectangle(cornerRadius: 12))
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Password", text: $password) Text("Platform")
.textFieldStyle(.plain) .font(.largeTitle.weight(.bold))
.padding(14) .foregroundStyle(Color.text1)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.textContentType(.password)
}
.padding(.horizontal, 32)
if let error = authManager.loginError { Text("Sign in to your dashboard")
Text(error) .font(.subheadline)
.font(.caption) .foregroundStyle(Color.text3)
.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)
} }
}
.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() // Form
Spacer() 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())
} }
} }

View File

@@ -1,51 +1,108 @@
import Foundation import Foundation
@Observable @MainActor @Observable
final class FoodSearchViewModel { final class FoodSearchViewModel {
var query = "" var searchText = ""
var results: [FoodItem] = [] var searchResults: [FoodItem] = []
var recentFoods: [FoodItem] = [] var recentFoods: [FoodItem] = []
var allFoods: [FoodItem] = []
var isSearching = false 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<Void, Never>? private var searchTask: Task<Void, Never>?
func loadInitial() async { var displayedFoods: [FoodItem] {
isLoadingInitial = true if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
do { return recentFoods
async let r = api.getRecentFoods()
async let a = api.getFoods()
recentFoods = try await r
allFoods = try await a
} catch {
// Silently fail
} }
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() { func search() {
let query = searchText.trimmingCharacters(in: .whitespaces)
// Cancel previous search
searchTask?.cancel() searchTask?.cancel()
let q = query.trimmingCharacters(in: .whitespaces)
guard q.count >= 2 else { guard !query.isEmpty else {
results = [] searchResults = []
isSearching = false isSearching = false
return return
} }
guard query.count >= 2 else {
return
}
isSearching = true isSearching = true
searchTask = Task { searchTask = Task {
// Debounce
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
do { do {
let items = try await api.searchFoods(query: q) let results = try await repo.searchFoods(query: query)
if !Task.isCancelled { guard !Task.isCancelled else { return }
results = items searchResults = results
isSearching = false
}
} catch { } catch {
if !Task.isCancelled { guard !Task.isCancelled else { return }
isSearching = false 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
}
} }

View File

@@ -1,54 +1,23 @@
import Foundation import Foundation
@Observable @MainActor @Observable
final class GoalsViewModel { final class GoalsViewModel {
var calories: String = "" var goal: DailyGoal = .defaultGoal
var protein: String = "" var isLoading = true
var carbs: String = "" var errorMessage: String?
var fat: String = ""
var sugar: String = ""
var fiber: String = ""
var isLoading = false
var isSaving = false
var error: String?
var saved = false
private let api = FitnessAPI() private let repo = FitnessRepository.shared
func load(date: String) async { func load() async {
isLoading = true isLoading = true
errorMessage = nil
do { do {
let goals = try await api.getGoals(date: date) goal = try await repo.goals(for: Date().apiDateString, forceRefresh: true)
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))
} catch { } catch {
self.error = error.localizedDescription errorMessage = error.localizedDescription
} }
isLoading = false 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
}
} }

View File

@@ -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..<numberOfDays {
let date = Date().adding(days: -i)
let dateString = date.apiDateString
let entries = try await repo.entries(for: dateString, forceRefresh: i == 0)
let goal = try await repo.goals(for: dateString)
results.append(HistoryDay(
date: date,
dateString: dateString,
entries: entries,
goal: goal
))
}
days = results
} catch {
errorMessage = error.localizedDescription
days = results // Show what we have
}
isLoading = false
}
}

View File

@@ -1,37 +1,45 @@
import Foundation import Foundation
@Observable @MainActor @Observable
final class TemplatesViewModel { final class TemplatesViewModel {
var templates: [MealTemplate] = [] var templates: [MealTemplate] = []
var isLoading = false var isLoading = true
var error: String? var errorMessage: String?
var logSuccess: String? var isLogging = false
var loggedTemplateId: String?
private let api = FitnessAPI() private let repo = FitnessRepository.shared
func load() async { func load() async {
isLoading = true isLoading = true
error = nil errorMessage = nil
do { do {
templates = try await api.getTemplates() templates = try await repo.templates(forceRefresh: true)
} catch { } catch {
self.error = error.localizedDescription errorMessage = error.localizedDescription
} }
isLoading = false isLoading = false
} }
func logTemplate(_ template: MealTemplate, date: String) async { func logTemplate(_ template: MealTemplate, date: String, onComplete: @escaping () -> Void) async {
isLogging = true
loggedTemplateId = template.id
do { do {
try await api.logTemplate(id: template.id, date: date) try await repo.logTemplate(id: template.id, date: date)
logSuccess = "Logged \(template.name)" loggedTemplateId = nil
// Refresh the repository onComplete()
await FitnessRepository.shared.loadDay(date: date)
} catch { } catch {
self.error = error.localizedDescription errorMessage = "Failed to log template: \(error.localizedDescription)"
loggedTemplateId = nil
} }
isLogging = false
} }
var groupedByMeal: [MealType: [MealTemplate]] { var groupedTemplates: [String: [MealTemplate]] {
Dictionary(grouping: templates, by: { $0.mealType }) Dictionary(grouping: templates, by: \.mealType)
} }
} }

View File

@@ -1,36 +1,111 @@
import Foundation import Foundation
@Observable @MainActor @Observable
final class TodayViewModel { final class TodayViewModel {
var entries: [FoodEntry] = []
var goal: DailyGoal = .defaultGoal
var selectedDate: Date = Date() var selectedDate: Date = Date()
let repository = FitnessRepository.shared var isLoading = true
var errorMessage: String?
var expandedMeals: Set<String> = Set(MealType.allCases.map(\.rawValue))
private let repo = FitnessRepository.shared
// MARK: - Computed Properties
var dateString: String { var dateString: String {
selectedDate.apiDateString selectedDate.apiDateString
} }
var displayDate: String { var mealGroups: [MealGroup] {
if selectedDate.isToday { MealType.allCases.map { meal in
return "Today" 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 { 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() { func goToNextDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate selectedDate = selectedDate.adding(days: 1)
Task { await load() } Task { await load() }
} }
func nextDay() { func goToPreviousDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate selectedDate = selectedDate.adding(days: -1)
Task { await load() } 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 { 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"
}
} }
} }

View File

@@ -2,170 +2,238 @@ import SwiftUI
struct AddFoodSheet: View { struct AddFoodSheet: View {
let food: FoodItem let food: FoodItem
let mealType: MealType @Binding var quantity: Double
let dateString: String @Binding var mealType: MealType
let onAdded: () -> Void let isAdding: Bool
let onAdd: () -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var quantity: Double = 1.0 @State private var quantityText: String = "1"
@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 }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { VStack(spacing: 24) {
VStack(spacing: 20) { // Food info header
// Food header foodHeader
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))
}
Text(food.name) // Quantity input
.font(.title3.bold()) quantitySection
.foregroundStyle(Color.textPrimary)
if let serving = food.servingSize { // Meal picker
Text(serving) mealPickerSection
.font(.caption)
.foregroundStyle(Color.textSecondary) // 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)
// Quantity .padding(.vertical, 16)
VStack(spacing: 8) { .background(Color.accentWarm)
Text("Quantity") .foregroundStyle(.white)
.font(.subheadline.bold()) .clipShape(RoundedRectangle(cornerRadius: 14))
.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))
} }
.padding(20) .disabled(isAdding || quantity <= 0)
} }
.padding(20)
.background(Color.canvas) .background(Color.canvas)
.navigationTitle("Add Food") .navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() } 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 { private var mealPickerSection: some View {
HStack { VStack(alignment: .leading, spacing: 8) {
Text(label) Text("Meal")
.font(.subheadline) .font(.caption.weight(.semibold))
.foregroundStyle(Color.textSecondary) .foregroundStyle(Color.text3)
Spacer() .textCase(.uppercase)
Text(value)
.font(.subheadline.bold()) HStack(spacing: 8) {
.foregroundStyle(Color.textPrimary) 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)
}
} }

View File

@@ -2,159 +2,257 @@ import SwiftUI
struct EntryDetailView: View { struct EntryDetailView: View {
let entry: FoodEntry let entry: FoodEntry
let dateString: String let onDelete: () -> Void
let onUpdateQuantity: (Double) -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var quantity: Double @State private var editQuantity: String
@State private var isDeleting = false @State private var showDeleteConfirm = false
@State private var isSaving = false
init(entry: FoodEntry, dateString: String) { init(entry: FoodEntry, onDelete: @escaping () -> Void, onUpdateQuantity: @escaping (Double) -> Void) {
self.entry = entry self.entry = entry
self.dateString = dateString self.onDelete = onDelete
_quantity = State(initialValue: entry.quantity) 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 { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
// Food name // Header
Text(entry.foodName) entryHeader
.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))
// Quantity editor // Quantity editor
VStack(spacing: 8) { quantityEditor
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)
}
}
}
if quantity != entry.quantity { // Macros grid
Button { macrosGrid
isSaving = true
Task { // Details
_ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity) detailsSection
isSaving = false
dismiss() // Delete button
} Button(role: .destructive) {
} label: { showDeleteConfirm = true
Group { } label: {
if isSaving { HStack(spacing: 8) {
ProgressView().tint(.white) Image(systemName: "trash")
} else { Text("Delete Entry")
Text("Update Quantity")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
} }
.buttonStyle(.borderedProminent) .font(.body.weight(.medium))
.tint(Color.accentWarm) .foregroundStyle(Color.error)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.error.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12)) .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) .padding(20)
} }
.background(Color.canvas) .background(Color.canvas)
.navigationTitle("Entry Detail") .navigationTitle("Entry Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() } 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) { VStack(spacing: 4) {
Text(value) Text("\(Int(value))")
.font(.title3.bold()) .font(.title3.weight(.bold))
.foregroundStyle(Color.textPrimary) .foregroundStyle(color)
Text("\(label) (\(unit))") Text(label)
.font(.caption) .font(.caption2)
.foregroundStyle(Color.textSecondary) .foregroundStyle(Color.text3)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(12) .padding(.vertical, 10)
.background(Color.surfaceSecondary) .background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10)) .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)
}
} }

View File

@@ -1,83 +1,75 @@
import SwiftUI import SwiftUI
enum FitnessTab: String, CaseIterable {
case today = "Today"
case templates = "Templates"
case goals = "Goals"
case foods = "Foods"
}
struct FitnessTabView: View { struct FitnessTabView: View {
@State private var selectedTab: FitnessTab = .today @State private var selectedTab: FitnessTab = .today
@State private var showAssistant = false
@State private var todayVM = TodayViewModel() @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 { var body: some View {
NavigationStack { NavigationStack {
ZStack(alignment: .bottomTrailing) { VStack(spacing: 0) {
VStack(spacing: 0) { // Custom segmented control
// Tab bar tabBar
HStack(spacing: 24) {
ForEach(FitnessTab.allCases, id: \.self) { tab in // Content
Button { Group {
withAnimation(.easeInOut(duration: 0.2)) { switch selectedTab {
selectedTab = tab case .today:
} TodayView(viewModel: todayVM, showFoodSearch: $showFoodSearch)
} label: { case .history:
Text(tab.rawValue) HistoryView()
.font(.subheadline) case .templates:
.fontWeight(selectedTab == tab ? .bold : .medium) TemplatesView(dateString: todayVM.dateString) {
.foregroundStyle(selectedTab == tab ? Color.accentWarm : Color.textSecondary) Task { await todayVM.load() }
.padding(.bottom, 8)
.overlay(alignment: .bottom) {
if selectedTab == tab {
Rectangle()
.fill(Color.accentWarm)
.frame(height: 2)
}
}
}
} }
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()) .background(Color.canvas)
.toolbar(.hidden, for: .navigationBar) .navigationTitle("Fitness")
.sheet(isPresented: $showAssistant) { .navigationBarTitleDisplayMode(.large)
AssistantChatView(entryDate: todayVM.dateString) { .sheet(isPresented: $showFoodSearch) {
FoodSearchView(date: todayVM.dateString) {
Task { await todayVM.load() } 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)
}
}
} }

View File

@@ -1,119 +1,198 @@
import SwiftUI import SwiftUI
struct FoodSearchView: View { struct FoodSearchView: View {
let mealType: MealType let date: String
let dateString: String let onFoodAdded: () -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var vm = FoodSearchViewModel() @State private var viewModel = FoodSearchViewModel()
@State private var selectedFood: FoodItem? @FocusState private var searchFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
// Search bar // Search bar
HStack { searchBar
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)
if vm.isSearching || vm.isLoadingInitial { // Content
LoadingView() if viewModel.isSearching || viewModel.isLoadingRecent {
} else if !vm.query.isEmpty && vm.query.count >= 2 { LoadingView(message: viewModel.isSearching ? "Searching..." : "Loading recent...")
// Search results } else if viewModel.displayedFoods.isEmpty && !viewModel.isShowingRecent {
List { EmptyStateView(
Section { icon: "magnifyingglass",
ForEach(vm.results) { food in title: "No results",
foodRow(food) subtitle: "Try a different search term"
} )
} header: {
Text("\(vm.results.count) results")
}
}
.listStyle(.plain)
} else { } else {
// Default: recent + all foodList
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)
} }
} }
.background(Color.canvas) .background(Color.canvas)
.navigationTitle("Add Food") .navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() } Button("Cancel") {
dismiss()
}
.foregroundStyle(Color.text3)
} }
} }
} .sheet(isPresented: $viewModel.showAddSheet) {
.task { if let food = viewModel.selectedFood {
await vm.loadInitial() AddFoodSheet(
} food: food,
.sheet(item: $selectedFood) { food in quantity: $viewModel.addQuantity,
AddFoodSheet(food: food, mealType: mealType, dateString: dateString) { mealType: $viewModel.addMealType,
dismiss() 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 { private var searchBar: some View {
Button { HStack(spacing: 10) {
selectedFood = food Image(systemName: "magnifyingglass")
} label: { .foregroundStyle(Color.text4)
HStack {
if let img = food.imageFilename { TextField("Search foods...", text: $viewModel.searchText)
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in .textFieldStyle(.plain)
image.resizable().aspectRatio(contentMode: .fill) .autocorrectionDisabled()
} placeholder: { .textInputAutocapitalization(.never)
Color.surfaceSecondary .focused($searchFocused)
} .onSubmit {
.frame(width: 40, height: 40) viewModel.search()
.clipShape(RoundedRectangle(cornerRadius: 8)) }
.onChange(of: viewModel.searchText) {
viewModel.search()
} }
VStack(alignment: .leading, spacing: 2) { if !viewModel.searchText.isEmpty {
Text(food.name) Button {
.font(.subheadline) viewModel.searchText = ""
.foregroundStyle(Color.textPrimary) viewModel.searchResults = []
Text("\(Int(food.calories)) cal") } label: {
.font(.caption) Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textSecondary) .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)
} }
} }

View File

@@ -1,85 +1,139 @@
import SwiftUI import SwiftUI
struct GoalsView: View { struct GoalsView: View {
let dateString: String @State private var viewModel = GoalsViewModel()
@State private var vm = GoalsViewModel()
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 16) { if viewModel.isLoading {
if vm.isLoading { LoadingView(message: "Loading goals...")
LoadingView() .frame(height: 300)
} else { } else {
VStack(spacing: 12) { VStack(spacing: 20) {
goalField("Calories", value: $vm.calories, unit: "kcal") // Header
goalField("Protein", value: $vm.protein, unit: "g") Text("Your daily targets")
goalField("Carbs", value: $vm.carbs, unit: "g") .font(.headline)
goalField("Fat", value: $vm.fat, unit: "g") .foregroundStyle(Color.text1)
goalField("Sugar", value: $vm.sugar, unit: "g") .frame(maxWidth: .infinity, alignment: .leading)
goalField("Fiber", value: $vm.fiber, unit: "g")
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal, 16)
Button { // Goals cards
Task { await vm.save() } goalCard(
} label: { label: "Calories",
Group { value: viewModel.goal.calories,
if vm.isSaving { unit: "kcal",
ProgressView().tint(.white) icon: "flame.fill",
} else { color: .caloriesColor
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)
if vm.saved { goalCard(
Text("Goals saved!") 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) .font(.caption)
.foregroundStyle(Color.emerald) .foregroundStyle(Color.text3)
}
if let err = vm.error {
ErrorBanner(message: err)
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
} }
.padding(16)
Spacer(minLength: 80)
} }
.padding(.top, 8)
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(16)
}
}
.refreshable {
await viewModel.load()
} }
.task { .task {
await vm.load(date: dateString) await viewModel.load()
} }
} }
private func goalField(_ label: String, value: Binding<String>, unit: String) -> some View { private func goalCard(label: String, value: Double, unit: String, icon: String, color: Color) -> some View {
HStack { HStack(spacing: 16) {
Text(label) ZStack {
.font(.subheadline) RoundedRectangle(cornerRadius: 12)
.foregroundStyle(Color.textSecondary) .fill(color.opacity(0.1))
.frame(width: 80, alignment: .leading) .frame(width: 48, height: 48)
TextField("0", text: value)
.keyboardType(.numberPad) Image(systemName: icon)
.textFieldStyle(.plain) .font(.title3)
.font(.subheadline.bold()) .foregroundStyle(color)
.padding(10) }
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 2) {
Text(unit) Text(label)
.font(.caption) .font(.subheadline.weight(.medium))
.foregroundStyle(Color.textTertiary) .foregroundStyle(Color.text2)
.frame(width: 32)
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)
} }
} }

View File

@@ -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)
}
}
}

View File

@@ -1,131 +1,261 @@
import SwiftUI import SwiftUI
struct MealSectionView: View { struct MealSectionView: View {
let meal: MealType let group: MealGroup
let entries: [FoodEntry] let isExpanded: Bool
let mealCalories: Double let onToggle: () -> Void
let onDelete: (FoodEntry) -> 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? @State private var selectedEntry: FoodEntry?
private var mealColor: Color {
Color.mealColor(for: group.meal.rawValue)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Header // Header
Button { Button(action: onToggle) {
withAnimation(.easeInOut(duration: 0.2)) { HStack(spacing: 10) {
isExpanded.toggle() Image(systemName: group.meal.icon)
} .font(.body)
} label: { .foregroundStyle(mealColor)
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)
.frame(width: 28) .frame(width: 28)
VStack(alignment: .leading, spacing: 2) { Text(group.meal.displayName)
Text(meal.displayName) .font(.subheadline.weight(.semibold))
.font(.headline) .foregroundStyle(Color.text1)
.foregroundStyle(Color.textPrimary)
Text("\(entries.count) item\(entries.count == 1 ? "" : "s")") if !group.entries.isEmpty {
.font(.caption) Text("\(group.entries.count)")
.foregroundStyle(Color.textSecondary) .font(.caption2.weight(.bold))
.foregroundStyle(mealColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(mealColor.opacity(0.1))
.clipShape(Capsule())
} }
.padding(.leading, 8)
Spacer() Spacer()
Text("\(Int(mealCalories)) cal") if !group.entries.isEmpty {
.font(.subheadline.bold()) Text("\(Int(group.totalCalories)) kcal")
.foregroundStyle(meal.color) .font(.caption.weight(.semibold))
.foregroundStyle(Color.text3)
}
Image(systemName: "chevron.right") Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2) .font(.caption.weight(.semibold))
.foregroundStyle(Color.textTertiary) .foregroundStyle(Color.text4)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.padding(.leading, 8)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 14)
} }
.buttonStyle(.plain)
// Entries // 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 { if isExpanded {
Button { if group.entries.isEmpty {
showFoodSearch = true emptyMealView
} label: { } else {
HStack { Divider()
Image(systemName: "plus.circle.fill") .padding(.horizontal, 16)
.foregroundStyle(meal.color.opacity(0.6))
Text("Add food") ForEach(group.entries) { entry in
.font(.subheadline) SwipeToDeleteRow(onDelete: { onDelete(entry) }) {
.foregroundStyle(Color.textSecondary) 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)) .background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 16) .shadow(color: .black.opacity(0.03), radius: 4, y: 2)
.sheet(isPresented: $showFoodSearch) {
FoodSearchView(mealType: meal, dateString: dateString)
}
.sheet(item: $selectedEntry) { entry in .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 { private var emptyMealView: some View {
HStack { Button(action: onAddFood) {
VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) {
Text(entry.foodName) Image(systemName: "plus.circle")
.foregroundStyle(mealColor)
Text("Add food")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.textPrimary) .foregroundStyle(Color.text3)
.lineLimit(1)
if entry.quantity != 1 {
Text("x\(String(format: "%.1f", entry.quantity))")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
} }
Spacer() .frame(maxWidth: .infinity)
Text("\(Int(entry.calories * entry.quantity))") .padding(.vertical, 16)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
} }
.padding(.vertical, 6) .buttonStyle(.plain)
.padding(.horizontal, 16) }
}
// MARK: - Swipe to Delete Row
struct SwipeToDeleteRow<Content: View>: View {
let onDelete: () -> Void
@ViewBuilder let content: () -> Content
@State private var offset: CGFloat = 0
@State private var showDelete = false
private let deleteThreshold: CGFloat = -80
private let deleteWidth: CGFloat = 80
var body: some View {
ZStack(alignment: .trailing) {
// Delete background
HStack {
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)
} }
} }

View File

@@ -2,102 +2,169 @@ import SwiftUI
struct TemplatesView: View { struct TemplatesView: View {
let dateString: String let dateString: String
@State private var vm = TemplatesViewModel() let onTemplateLogged: () -> Void
@State private var templateToLog: MealTemplate?
@State private var showConfirm = false @State private var viewModel = TemplatesViewModel()
@State private var confirmTemplate: MealTemplate?
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 16) { if viewModel.isLoading {
if vm.isLoading { LoadingView(message: "Loading templates...")
LoadingView() .frame(height: 300)
} else if vm.templates.isEmpty { } else if viewModel.templates.isEmpty {
EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app") EmptyStateView(
} else { 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 ForEach(MealType.allCases) { meal in
let templates = vm.groupedByMeal[meal] ?? [] let templates = viewModel.groupedTemplates[meal.rawValue] ?? []
if !templates.isEmpty { if !templates.isEmpty {
VStack(alignment: .leading, spacing: 8) { templateSection(meal: meal, templates: templates)
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)
}
}
} }
} }
}
if let msg = vm.logSuccess { // Ungrouped
Text(msg) let ungrouped = viewModel.templates.filter { template in
.font(.caption) !MealType.allCases.map(\.rawValue).contains(template.mealType)
.foregroundStyle(Color.emerald) }
.padding(.top, 4) if !ungrouped.isEmpty {
templateSection(mealLabel: "Other", icon: "ellipsis.circle.fill", color: .text3, templates: ungrouped)
}
} }
.padding(16)
if let err = vm.error {
ErrorBanner(message: err)
}
Spacer(minLength: 80)
} }
.padding(.top, 8)
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(16)
}
}
.refreshable {
await viewModel.load()
} }
.task { .task {
await vm.load() await viewModel.load()
} }
.alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in .confirmationDialog(
Button("Log") { "Log Template",
Task { await vm.logTemplate(template, date: dateString) } 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) {} Button("Cancel", role: .cancel) {}
} message: { template in } 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 { private func templateSection(meal: MealType, templates: [MealTemplate]) -> some View {
HStack { 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) { VStack(alignment: .leading, spacing: 4) {
Text(template.name) Text(template.name)
.font(.subheadline.bold()) .font(.subheadline.weight(.medium))
.foregroundStyle(Color.textPrimary) .foregroundStyle(Color.text1)
.lineLimit(1)
HStack(spacing: 8) { HStack(spacing: 8) {
if let cal = template.totalCalories { Text("\(Int(template.calories)) kcal")
Text("\(Int(cal)) cal") .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() Spacer()
Button {
templateToLog = template // Log button
showConfirm = true Button(action: onLog) {
} label: { if isLogging {
Text("Log meal") ProgressView()
.font(.caption.bold()) .controlSize(.small)
.foregroundStyle(.white) .tint(Color.accentWarm)
.padding(.horizontal, 12) } else {
.padding(.vertical, 6) Image(systemName: "plus.circle.fill")
.background(Color.accentWarm) .font(.title3)
.clipShape(RoundedRectangle(cornerRadius: 8)) .foregroundStyle(Color.accentWarm)
}
} }
.disabled(isLogging)
} }
.padding(14) .padding(14)
.background(Color.surface) .background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.03), radius: 4, y: 1) .shadow(color: .black.opacity(0.03), radius: 4, y: 2)
.padding(.horizontal, 16)
} }
} }

View File

@@ -2,95 +2,170 @@ import SwiftUI
struct TodayView: View { struct TodayView: View {
@Bindable var viewModel: TodayViewModel @Bindable var viewModel: TodayViewModel
@State private var showFoodSearch = false @Binding var showFoodSearch: Bool
var body: some View { var body: some View {
ScrollView { ZStack(alignment: .bottomTrailing) {
VStack(spacing: 16) { ScrollView {
// Date selector VStack(spacing: 16) {
HStack { // Date selector
Button { viewModel.previousDay() } label: { dateSelector
Image(systemName: "chevron.left")
.font(.headline) if viewModel.isLoading {
.foregroundStyle(Color.accentWarm) LoadingView(message: "Loading entries...")
} .frame(height: 200)
Spacer() } else {
Text(viewModel.displayDate) // Macro summary card
.font(.headline) macroSummaryCard
.foregroundStyle(Color.textPrimary)
Spacer() // Meal sections
Button { viewModel.nextDay() } label: { ForEach(viewModel.mealGroups) { group in
Image(systemName: "chevron.right") MealSectionView(
.font(.headline) group: group,
.foregroundStyle(Color.accentWarm) isExpanded: viewModel.expandedMeals.contains(group.meal.rawValue),
} onToggle: { viewModel.toggleMeal(group.meal.rawValue) },
} onDelete: { entry in
.padding(.horizontal, 20) Task { await viewModel.deleteEntry(entry) }
.padding(.top, 8) },
.gesture( onAddFood: {
DragGesture(minimumDistance: 50) showFoodSearch = true
.onEnded { value in }
if value.translation.width > 0 { )
viewModel.previousDay()
} else {
viewModel.nextDay()
}
} }
)
// Macro summary card // Bottom spacing for FAB
if !viewModel.repository.isLoading { Spacer()
macroSummaryCard .frame(height: 80)
}
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
Task { await viewModel.load() }
}
.padding(.horizontal, 4)
}
} }
.padding(16)
// 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)
} }
} .refreshable {
.refreshable { await viewModel.load()
await viewModel.load() }
// Floating add button
addButton
} }
.task { .task {
await viewModel.load() await viewModel.load()
} }
} }
private var macroSummaryCard: some View { // MARK: - Date Selector
let repo = viewModel.repository
let goals = repo.goals
return VStack(spacing: 12) { private var dateSelector: some View {
HStack(spacing: 20) { HStack(spacing: 0) {
LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories) Button {
viewModel.goToPreviousDay()
} label: {
Image(systemName: "chevron.left")
.font(.body.weight(.semibold))
.foregroundStyle(Color.accentWarm)
.frame(width: 44, height: 44)
}
VStack(spacing: 8) { Spacer()
MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue)
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange) VStack(spacing: 2) {
MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple) 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) .background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 2) .shadow(color: .black.opacity(0.04), radius: 8, y: 4)
.padding(.horizontal, 16) }
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)
} }
} }

View File

@@ -1,161 +1,188 @@
import SwiftUI import SwiftUI
import PhotosUI
struct HomeView: View { struct HomeView: View {
@Environment(AuthManager.self) private var authManager @Environment(AuthManager.self) private var authManager
@State private var vm = HomeViewModel() @State private var viewModel = HomeViewModel()
@State private var showAssistant = false
@State private var showProfileMenu = false
@State private var selectedPhoto: PhotosPickerItem?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack(alignment: .bottomTrailing) { ScrollView {
// Background VStack(spacing: 20) {
if let bg = vm.backgroundImage { if viewModel.isLoading {
Image(uiImage: bg) LoadingView(message: "Loading dashboard...")
.resizable() .frame(height: 300)
.aspectRatio(contentMode: .fill) } else {
.ignoresSafeArea() // Quick Stats Card
.overlay(Color.black.opacity(0.2).ignoresSafeArea()) caloriesSummaryCard
}
else {
Color.canvas.ignoresSafeArea()
}
ScrollView { // Macros Card
VStack(spacing: 16) { macrosCard
// Top bar
HStack { // Quick Actions
VStack(alignment: .leading, spacing: 2) { quickActionsCard
Text("Dashboard") }
.font(.title2.bold())
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary) if let error = viewModel.errorMessage {
if let name = authManager.user?.displayName ?? authManager.user?.username { ErrorBanner(message: error) {
Text("Welcome, \(name)") Task { await viewModel.load() }
.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)
}
} }
.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)
} }
} }
.padding(16)
// 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)
} }
.toolbar(.hidden, for: .navigationBar) .background(Color.canvas)
.sheet(isPresented: $showAssistant) { .navigationTitle("Dashboard")
AssistantChatView(entryDate: Date().apiDateString) { .toolbar {
Task { await vm.loadData() } ToolbarItem(placement: .topBarTrailing) {
} Menu {
} Button(role: .destructive) {
.onChange(of: selectedPhoto) { _, newValue in authManager.logout()
guard let item = newValue else { return } } label: {
Task { Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
if let data = try? await item.loadTransferable(type: Data.self), }
let image = UIImage(data: data) { } label: {
vm.setBackground(image) Image(systemName: "person.circle.fill")
.font(.title3)
.foregroundStyle(Color.accentWarm)
} }
selectedPhoto = nil
} }
} }
.refreshable {
await viewModel.load()
}
.task { .task {
await vm.loadData() await viewModel.load()
} }
} }
} }
private var calorieWidget: some View { private var caloriesSummaryCard: some View {
let hasBg = vm.backgroundImage != nil VStack(spacing: 16) {
return VStack(spacing: 8) { HStack {
LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal) VStack(alignment: .leading, spacing: 4) {
Text("Calories") Text("Today")
.font(.caption.bold()) .font(.headline)
.foregroundStyle(hasBg ? .white : Color.textSecondary) .foregroundStyle(Color.text1)
} Text(Date().displayString)
.padding(16) .font(.subheadline)
.frame(maxWidth: .infinity) .foregroundStyle(Color.text3)
.background { }
if hasBg { Spacer()
RoundedRectangle(cornerRadius: 16) Text("\(viewModel.entryCount) entries")
.fill(.ultraThinMaterial) .font(.caption)
} else { .foregroundStyle(Color.text4)
RoundedRectangle(cornerRadius: 16) .padding(.horizontal, 10)
.fill(Color.surface) .padding(.vertical, 4)
.shadow(color: .black.opacity(0.04), radius: 8, y: 2) .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 { private var macrosCard: some View {
let hasBg = vm.backgroundImage != nil VStack(spacing: 14) {
let repo = FitnessRepository.shared
return VStack(alignment: .leading, spacing: 10) {
Text("Macros") Text("Macros")
.font(.caption.bold()) .font(.headline)
.foregroundStyle(hasBg ? .white : Color.textSecondary) .foregroundStyle(Color.text1)
MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true) .frame(maxWidth: .infinity, alignment: .leading)
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) 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) .padding(20)
.frame(maxWidth: .infinity) .background(Color.surface)
.background { .clipShape(RoundedRectangle(cornerRadius: 16))
if hasBg { .shadow(color: .black.opacity(0.04), radius: 8, y: 4)
RoundedRectangle(cornerRadius: 16) }
.fill(.ultraThinMaterial)
} else { private var quickActionsCard: some View {
RoundedRectangle(cornerRadius: 16) VStack(spacing: 12) {
.fill(Color.surface) Text("Quick Actions")
.shadow(color: .black.opacity(0.04), radius: 8, y: 2) .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))
} }
} }

View File

@@ -1,54 +1,49 @@
import SwiftUI import Foundation
import PhotosUI
@Observable @MainActor @Observable
final class HomeViewModel { final class HomeViewModel {
var backgroundImage: UIImage? var todayEntries: [FoodEntry] = []
var caloriesConsumed: Double = 0 var goal: DailyGoal = .defaultGoal
var caloriesGoal: Double = 2000 var isLoading = true
var isLoading = false var errorMessage: String?
private let bgKey = "homeBackgroundImage"
private let repo = FitnessRepository.shared private let repo = FitnessRepository.shared
init() { var totalCalories: Double {
loadBackgroundFromDefaults() 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 isLoading = true
errorMessage = nil
let today = Date().apiDateString let today = Date().apiDateString
await repo.loadDay(date: today)
caloriesConsumed = repo.totalCalories do {
caloriesGoal = repo.goals.calories 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 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
}
}
} }

View File

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

View File

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

View File

@@ -1,83 +1,96 @@
import SwiftUI import SwiftUI
struct MacroRing: View { struct MacroRing: View {
let consumed: Double let current: Double
let goal: Double let goal: Double
let color: Color let color: Color
var size: CGFloat = 80 let label: String
var lineWidth: CGFloat = 8 let unit: String
var showLabel: Bool = true var size: CGFloat = 72
var labelFontSize: CGFloat = 14 var lineWidth: CGFloat = 7
private var progress: Double { private var progress: Double {
guard goal > 0 else { return 0 } guard goal > 0 else { return 0 }
return min(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 { var body: some View {
ZStack { ZStack {
Circle() Circle()
.stroke(color.opacity(0.15), lineWidth: lineWidth) .stroke(color.opacity(0.12), lineWidth: lineWidth)
Circle() Circle()
.trim(from: 0, to: progress) .trim(from: 0, to: progress)
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .stroke(
color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.6), value: progress) .animation(.easeOut(duration: 0.5), value: progress)
if showLabel { VStack(spacing: 2) {
VStack(spacing: 0) { Text("\(Int(remaining))")
Text("\(Int(consumed))") .font(.system(size: size * 0.26, weight: .bold, design: .rounded))
.font(.system(size: labelFontSize, weight: .bold, design: .rounded)) .foregroundStyle(Color.text1)
.foregroundStyle(Color.textPrimary) Text("remaining")
if goal > 0 { .font(.system(size: size * 0.11, weight: .medium))
Text("/ \(Int(goal))") .foregroundStyle(Color.text4)
.font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded))
.foregroundStyle(Color.textSecondary)
}
}
} }
} }
.frame(width: size, height: size) .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)
}
}

View File

@@ -1,12 +1,53 @@
import SwiftUI import SwiftUI
extension Color { 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) { init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0 var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int) Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64 let a, r, g, b: UInt64
switch hex.count { switch hex.count {
case 3:
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: case 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: case 8:
@@ -23,25 +64,23 @@ extension Color {
) )
} }
// Core palette static func mealColor(for meal: String) -> Color {
static let canvas = Color(hex: "F5EFE6") switch meal.lowercased() {
static let surface = Color(hex: "FFFFFF") case "breakfast": return .breakfast
static let surfaceSecondary = Color(hex: "FAF7F2") case "lunch": return .lunch
static let accentWarm = Color(hex: "8B6914") case "dinner": return .dinner
static let accentWarmLight = Color(hex: "D4A843") case "snack": return .snack
static let emerald = Color(hex: "059669") default: return .text3
static let textPrimary = Color(hex: "1C1917") }
static let textSecondary = Color(hex: "78716C") }
static let textTertiary = Color(hex: "A8A29E")
static let border = Color(hex: "E7E5E4")
// Meal colors static func mealIcon(for meal: String) -> String {
static let breakfastColor = Color(hex: "F59E0B") switch meal.lowercased() {
static let lunchColor = Color(hex: "059669") case "breakfast": return "sunrise.fill"
static let dinnerColor = Color(hex: "8B5CF6") case "lunch": return "sun.max.fill"
static let snackColor = Color(hex: "EC4899") case "dinner": return "moon.fill"
case "snack": return "leaf.fill"
// Chat default: return "fork.knife"
static let userBubble = Color(hex: "8B6914").opacity(0.15) }
static let assistantBubble = Color(hex: "F5F5F4") }
} }

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
extension Date { extension Date {
/// Format as yyyy-MM-dd for API calls
var apiDateString: String { var apiDateString: String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd" formatter.dateFormat = "yyyy-MM-dd"
@@ -8,15 +9,24 @@ extension Date {
return formatter.string(from: self) return formatter.string(from: self)
} }
/// Display format: "Mon, Apr 2"
var displayString: String { var displayString: String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d" formatter.dateFormat = "EEE, MMM d"
return formatter.string(from: self) 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 { var shortDisplayString: String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy" formatter.dateFormat = "MMM d"
return formatter.string(from: self) return formatter.string(from: self)
} }
@@ -24,10 +34,25 @@ extension Date {
Calendar.current.isDateInToday(self) 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? { static func from(apiString: String) -> Date? {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd" formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.date(from: apiString) 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
}
} }