From 2d9e7ad20bb561c68f35cf2797b6e2c98ecd2ec4 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Thu, 9 Jul 2026 19:07:01 +0900 Subject: [PATCH] remove old project --- myApp/Expiranner | 1 + myApp/HaruDanim/CLAUDE.md | 22 - .../HaruDanim.xcodeproj/project.pbxproj | 333 --------------- .../contents.xcworkspacedata | 7 - .../UserInterfaceState.xcuserstate | Bin 30547 -> 0 bytes .../xcschemes/xcschememanagement.plist | 14 - .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 35 -- .../IOS/Assets.xcassets/Contents.json | 6 - myApp/HaruDanim/IOS/ContentView.swift | 18 - myApp/HaruDanim/IOS/HaruDanimApp.swift | 27 -- myApp/HaruDanim/IOS/Models/Schema.swift | 290 ------------- .../IOS/ViewModels/GoalViewModel.swift | 171 -------- .../IOS/ViewModels/TrackingViewModel.swift | 113 ----- myApp/HaruDanim/IOS/Views/ActionTabView.swift | 183 -------- .../HaruDanim/IOS/Views/GoalDetailView.swift | 401 ------------------ myApp/HaruDanim/IOS/Views/GoalTabView.swift | 146 ------- .../IOS/Views/MainDashboardView.swift | 235 ---------- myApp/HaruDanim/IOS/Views/MainTabView.swift | 48 --- myApp/HaruDanim/IOS/Views/RecordTabView.swift | 183 -------- myApp/HaruDanim/IOS/Views/TagTabView.swift | 126 ------ 21 files changed, 1 insertion(+), 2369 deletions(-) create mode 160000 myApp/Expiranner delete mode 100644 myApp/HaruDanim/CLAUDE.md delete mode 100644 myApp/HaruDanim/HaruDanim.xcodeproj/project.pbxproj delete mode 100644 myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate delete mode 100644 myApp/HaruDanim/HaruDanim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist delete mode 100644 myApp/HaruDanim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 myApp/HaruDanim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 myApp/HaruDanim/IOS/Assets.xcassets/Contents.json delete mode 100644 myApp/HaruDanim/IOS/ContentView.swift delete mode 100644 myApp/HaruDanim/IOS/HaruDanimApp.swift delete mode 100644 myApp/HaruDanim/IOS/Models/Schema.swift delete mode 100644 myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift delete mode 100644 myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift delete mode 100644 myApp/HaruDanim/IOS/Views/ActionTabView.swift delete mode 100644 myApp/HaruDanim/IOS/Views/GoalDetailView.swift delete mode 100644 myApp/HaruDanim/IOS/Views/GoalTabView.swift delete mode 100644 myApp/HaruDanim/IOS/Views/MainDashboardView.swift delete mode 100644 myApp/HaruDanim/IOS/Views/MainTabView.swift delete mode 100644 myApp/HaruDanim/IOS/Views/RecordTabView.swift delete mode 100644 myApp/HaruDanim/IOS/Views/TagTabView.swift diff --git a/myApp/Expiranner b/myApp/Expiranner new file mode 160000 index 0000000..74a95d4 --- /dev/null +++ b/myApp/Expiranner @@ -0,0 +1 @@ +Subproject commit 74a95d489240619326e8abc1725d15b6e62031ab diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md deleted file mode 100644 index 24d0275..0000000 --- a/myApp/HaruDanim/CLAUDE.md +++ /dev/null @@ -1,22 +0,0 @@ -# HaruDanim (하루 다님) Development Guidelines - -## Architecture & Stack -- **UI Framework**: SwiftUI -- **Data Storage**: SwiftData -- **Architecture**: MVVM (Model-View-ViewModel) + Observation (`@Observable`) -- **Minimum Target**: iOS 17.0+ (to fully utilize SwiftData and new Observation framework) - -## Coding Conventions -1. **Scope Limitation**: Never scan the entire project unnecessarily. Only read and edit files explicitly requested. -2. **Data Modeling**: - - Use `@Model` macro for all SwiftData models. - - Strictly implement relationships (e.g., Action <-> Tag many-to-many, Goal -> Quest one-to-many). - - Use raw value Enums for specific types (ActionType, QuestPeriod, Direction). -3. **UI/UX Guidelines**: - - Primary Green: Light `#2F6B4F`, Dark `#7FBF9E` - - Accent Yellow: Light `#D9A621`, Dark `#E8C558` - - Background: Light `#FAFAF6`, Dark `#111512` - - Use `SF Symbols` exclusively for iconography. -4. **Business Logic**: - - A day boundary starts at `AppSettings.dayStartHour` (default 00:00). - - Sessions crossing this boundary must be saved as one `TimeSession` but logically split when queried for statistics. diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/project.pbxproj b/myApp/HaruDanim/HaruDanim.xcodeproj/project.pbxproj deleted file mode 100644 index 6119b9e..0000000 --- a/myApp/HaruDanim/HaruDanim.xcodeproj/project.pbxproj +++ /dev/null @@ -1,333 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXFileReference section */ - 72CCE2A82FF7E9A50031A6A4 /* HaruDanim.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HaruDanim.app; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 72CCE2AA2FF7E9A50031A6A4 /* IOS */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = IOS; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 72CCE2A52FF7E9A50031A6A4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 72CCE29F2FF7E9A50031A6A4 = { - isa = PBXGroup; - children = ( - 72CCE2AA2FF7E9A50031A6A4 /* IOS */, - 72CCE2A92FF7E9A50031A6A4 /* Products */, - ); - sourceTree = ""; - }; - 72CCE2A92FF7E9A50031A6A4 /* Products */ = { - isa = PBXGroup; - children = ( - 72CCE2A82FF7E9A50031A6A4 /* HaruDanim.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 72CCE2A72FF7E9A50031A6A4 /* HaruDanim */ = { - isa = PBXNativeTarget; - buildConfigurationList = 72CCE2B32FF7E9A60031A6A4 /* Build configuration list for PBXNativeTarget "HaruDanim" */; - buildPhases = ( - 72CCE2A42FF7E9A50031A6A4 /* Sources */, - 72CCE2A52FF7E9A50031A6A4 /* Frameworks */, - 72CCE2A62FF7E9A50031A6A4 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 72CCE2AA2FF7E9A50031A6A4 /* IOS */, - ); - name = HaruDanim; - packageProductDependencies = ( - ); - productName = HaruDanim; - productReference = 72CCE2A82FF7E9A50031A6A4 /* HaruDanim.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 72CCE2A02FF7E9A50031A6A4 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2660; - LastUpgradeCheck = 2660; - TargetAttributes = { - 72CCE2A72FF7E9A50031A6A4 = { - CreatedOnToolsVersion = 26.6; - }; - }; - }; - buildConfigurationList = 72CCE2A32FF7E9A50031A6A4 /* Build configuration list for PBXProject "HaruDanim" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 72CCE29F2FF7E9A50031A6A4; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = 72CCE2A92FF7E9A50031A6A4 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 72CCE2A72FF7E9A50031A6A4 /* HaruDanim */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 72CCE2A62FF7E9A50031A6A4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 72CCE2A42FF7E9A50031A6A4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 72CCE2B12FF7E9A60031A6A4 /* 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 = 26.5; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - 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; - }; - 72CCE2B22FF7E9A60031A6A4 /* 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 = 26.5; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 72CCE2B42FF7E9A60031A6A4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - 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.yechan.HaruDanim; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 72CCE2B52FF7E9A60031A6A4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - 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.yechan.HaruDanim; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 72CCE2A32FF7E9A50031A6A4 /* Build configuration list for PBXProject "HaruDanim" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 72CCE2B12FF7E9A60031A6A4 /* Debug */, - 72CCE2B22FF7E9A60031A6A4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 72CCE2B32FF7E9A60031A6A4 /* Build configuration list for PBXNativeTarget "HaruDanim" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 72CCE2B42FF7E9A60031A6A4 /* Debug */, - 72CCE2B52FF7E9A60031A6A4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 72CCE2A02FF7E9A50031A6A4 /* Project object */; -} diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index b2c84c6505d510c9e9d19b6c361b97982e606d0a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30547 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 Oe~We+f15sa$Nv}VU%qkx diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist b/myApp/HaruDanim/HaruDanim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 3a2aa8e..0000000 --- a/myApp/HaruDanim/HaruDanim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - HaruDanim.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/myApp/HaruDanim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/HaruDanim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/myApp/HaruDanim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/myApp/HaruDanim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/HaruDanim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 2305880..0000000 --- a/myApp/HaruDanim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/myApp/HaruDanim/IOS/Assets.xcassets/Contents.json b/myApp/HaruDanim/IOS/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/myApp/HaruDanim/IOS/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift deleted file mode 100644 index abc6659..0000000 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// ContentView.swift -// HaruDanim -// -// Created by 송예찬 on 7/3/26. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - MainTabView() - } -} - -#Preview { - ContentView() -} diff --git a/myApp/HaruDanim/IOS/HaruDanimApp.swift b/myApp/HaruDanim/IOS/HaruDanimApp.swift deleted file mode 100644 index f80e844..0000000 --- a/myApp/HaruDanim/IOS/HaruDanimApp.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// HaruDanimApp.swift -// HaruDanim -// -// Created by 송예찬 on 7/3/26. -// - -import SwiftUI -import SwiftData - -@main -struct HaruDanimApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - .modelContainer(for: [ - Action.self, - Tag.self, - TimeSession.self, - CountEntry.self, - Goal.self, - Quest.self, - AppSettings.self - ]) - } -} diff --git a/myApp/HaruDanim/IOS/Models/Schema.swift b/myApp/HaruDanim/IOS/Models/Schema.swift deleted file mode 100644 index f0e90e0..0000000 --- a/myApp/HaruDanim/IOS/Models/Schema.swift +++ /dev/null @@ -1,290 +0,0 @@ -// -// Schema.swift -// HaruDanim -// -// SwiftData schema definitions: models, enums, and relationships. -// - -import Foundation -import SwiftData - -// MARK: - Enums - -/// Determines how an `Action` is tracked. -enum ActionType: String, Codable, CaseIterable { - /// Tracked via elapsed time (`TimeSession`). - case time - /// Tracked via discrete counts (`CountEntry`). - case count -} - -/// Lifecycle state of a `Goal`. -enum GoalStatus: String, Codable, CaseIterable { - case inProgress - case achieved - case failed - /// Awaiting user confirmation (e.g. period ended, result ambiguous). - case needsConfirmation -} - -/// The recurrence window a `Quest` is evaluated over. -enum QuestPeriod: String, Codable, CaseIterable { - case daily - case weekly - case monthly - case custom -} - -/// Whether the target value should be met by going above or below it. -enum QuestDirection: String, Codable, CaseIterable { - /// Succeed when the measured value is at or above the target. - case above - /// Succeed when the measured value is at or below the target. - case below -} - -// MARK: - Tag - -@Model -final class Tag { - @Attribute(.unique) var id: UUID - var name: String - /// Hex color string (e.g. `#2F6B4F`) for tag styling. - var colorHex: String - var createdAt: Date - - /// Actions carrying this tag (many-to-many). Inverse declared on `Action.tags`. - var actions: [Action] - - init( - id: UUID = UUID(), - name: String, - colorHex: String = "#2F6B4F", - createdAt: Date = .now, - actions: [Action] = [] - ) { - self.id = id - self.name = name - self.colorHex = colorHex - self.createdAt = createdAt - self.actions = actions - } -} - -// MARK: - Action - -@Model -final class Action { - @Attribute(.unique) var id: UUID - var name: String - var typeRaw: String - /// SF Symbol name used for iconography. - var iconName: String - var createdAt: Date - - /// Tags applied to this action (many-to-many, owning side of the inverse). - @Relationship(inverse: \Tag.actions) - var tags: [Tag] - - /// Time-based tracking records. Deleting the action cascades to its sessions. - @Relationship(deleteRule: .cascade, inverse: \TimeSession.action) - var timeSessions: [TimeSession] - - /// Count-based tracking records. Deleting the action cascades to its entries. - @Relationship(deleteRule: .cascade, inverse: \CountEntry.action) - var countEntries: [CountEntry] - - var type: ActionType { - get { ActionType(rawValue: typeRaw) ?? .time } - set { typeRaw = newValue.rawValue } - } - - init( - id: UUID = UUID(), - name: String, - type: ActionType = .time, - iconName: String = "circle", - createdAt: Date = .now, - tags: [Tag] = [], - timeSessions: [TimeSession] = [], - countEntries: [CountEntry] = [] - ) { - self.id = id - self.name = name - self.typeRaw = type.rawValue - self.iconName = iconName - self.createdAt = createdAt - self.tags = tags - self.timeSessions = timeSessions - self.countEntries = countEntries - } -} - -// MARK: - TimeSession - -@Model -final class TimeSession { - @Attribute(.unique) var id: UUID - var startTime: Date - /// `nil` while the session is still running. - var endTime: Date? - - /// The action this session belongs to (inverse of `Action.timeSessions`). - var action: Action? - - init( - id: UUID = UUID(), - startTime: Date = .now, - endTime: Date? = nil, - action: Action? = nil - ) { - self.id = id - self.startTime = startTime - self.endTime = endTime - self.action = action - } -} - -// MARK: - CountEntry - -@Model -final class CountEntry { - @Attribute(.unique) var id: UUID - var timestamp: Date - /// Number of units recorded in this entry. - var amount: Int - - /// The action this entry belongs to (inverse of `Action.countEntries`). - var action: Action? - - init( - id: UUID = UUID(), - timestamp: Date = .now, - amount: Int = 1, - action: Action? = nil - ) { - self.id = id - self.timestamp = timestamp - self.amount = amount - self.action = action - } -} - -// MARK: - Goal - -@Model -final class Goal { - @Attribute(.unique) var id: UUID - var title: String - var statusRaw: String - /// Hex color string (e.g. `#2F6B4F`) for goal styling. - var colorHex: String - /// Inclusive date the goal begins being tracked. - var startDate: Date - /// Inclusive date the goal is evaluated through. - var endDate: Date - var createdAt: Date - - /// Quests composing this goal. Deleting the goal cascades to its quests. - @Relationship(deleteRule: .cascade, inverse: \Quest.goal) - var quests: [Quest] - - var status: GoalStatus { - get { GoalStatus(rawValue: statusRaw) ?? .inProgress } - set { statusRaw = newValue.rawValue } - } - - init( - id: UUID = UUID(), - title: String, - status: GoalStatus = .inProgress, - colorHex: String = "#2F6B4F", - startDate: Date = .now, - endDate: Date = .now, - createdAt: Date = .now, - quests: [Quest] = [] - ) { - self.id = id - self.title = title - self.statusRaw = status.rawValue - self.colorHex = colorHex - self.startDate = startDate - self.endDate = endDate - self.createdAt = createdAt - self.quests = quests - } -} - -// MARK: - Quest - -@Model -final class Quest { - @Attribute(.unique) var id: UUID - var title: String - var periodRaw: String - var directionRaw: String - /// The threshold this quest is measured against (seconds for time, units for count). - var targetValue: Double - /// Whether `targetValue` is expressed in seconds (`true`) or discrete units (`false`). - var usesTime: Bool - var createdAt: Date - - /// The goal this quest belongs to (inverse of `Goal.quests`). - var goal: Goal? - - /// The `Action` this quest measures, when it targets a single action. - var targetAction: Action? - /// The `Tag` this quest measures, when it targets a tag's aggregate. - var targetTag: Tag? - - var period: QuestPeriod { - get { QuestPeriod(rawValue: periodRaw) ?? .daily } - set { periodRaw = newValue.rawValue } - } - - var direction: QuestDirection { - get { QuestDirection(rawValue: directionRaw) ?? .above } - set { directionRaw = newValue.rawValue } - } - - init( - id: UUID = UUID(), - title: String, - period: QuestPeriod = .daily, - direction: QuestDirection = .above, - targetValue: Double = 0, - usesTime: Bool = true, - createdAt: Date = .now, - goal: Goal? = nil, - targetAction: Action? = nil, - targetTag: Tag? = nil - ) { - self.id = id - self.title = title - self.periodRaw = period.rawValue - self.directionRaw = direction.rawValue - self.targetValue = targetValue - self.usesTime = usesTime - self.createdAt = createdAt - self.goal = goal - self.targetAction = targetAction - self.targetTag = targetTag - } -} - -// MARK: - AppSettings - -@Model -final class AppSettings { - @Attribute(.unique) var id: UUID - /// Hour (0...23) at which a new logical day begins. Default 00:00. - var dayStartHour: Int - - init( - id: UUID = UUID(), - dayStartHour: Int = 0 - ) { - self.id = id - self.dayStartHour = dayStartHour - } -} diff --git a/myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift b/myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift deleted file mode 100644 index 83541fa..0000000 --- a/myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// GoalViewModel.swift -// HaruDanim -// -// Computes how much progress a `Quest` has accumulated within its current -// period, and evaluates whether it is being met given its direction. -// - -import Foundation -import SwiftData -import Observation - -/// The evaluated outcome of a quest for its current period. -enum QuestEvaluation { - /// `above` quest whose target has been reached. - case achieved - /// `above` quest still short of its target (period ongoing). - case inProgress - /// `below` quest still within its allowed limit. - case onTrack - /// `below` quest that has exceeded its limit. - case failed - /// Period type not yet supported (monthly / custom). - case unsupported -} - -/// A snapshot of a quest's accumulated value against its target for the -/// current period. `current` and `target` share units: seconds when -/// `usesTime`, discrete units otherwise. -struct QuestProgress { - let current: Double - let target: Double - let usesTime: Bool - let direction: QuestDirection - /// Whether the quest's period could actually be computed. - let isPeriodSupported: Bool - - /// Completion ratio clamped to `0...1`, suitable for a `ProgressView`. - /// For `below` quests this represents how much of the budget is used, so - /// a full bar means the limit has been reached. - var fraction: Double { - guard target > 0 else { return 0 } - return max(0, min(current / target, 1)) - } - - var evaluation: QuestEvaluation { - guard isPeriodSupported else { return .unsupported } - switch direction { - case .above: - return current >= target ? .achieved : .inProgress - case .below: - return current > target ? .failed : .onTrack - } - } -} - -@Observable -final class GoalViewModel { - - // MARK: - Public API - - /// Computes the current-period progress for `quest`, honoring the app's - /// logical day boundary (`dayStartHour`). Only `daily` and `weekly` - /// periods are supported; others return an unsupported, zero progress. - func progress(for quest: Quest, dayStartHour: Int = 0, now: Date = .now) -> QuestProgress { - guard let bounds = periodBounds(for: quest.period, dayStartHour: dayStartHour, now: now) else { - return QuestProgress( - current: 0, - target: quest.targetValue, - usesTime: quest.usesTime, - direction: quest.direction, - isPeriodSupported: false - ) - } - - let current: Double = quest.usesTime - ? accumulatedTime(for: quest, start: bounds.start, end: bounds.end, now: now) - : accumulatedCount(for: quest, start: bounds.start, end: bounds.end) - - return QuestProgress( - current: current, - target: quest.targetValue, - usesTime: quest.usesTime, - direction: quest.direction, - isPeriodSupported: true - ) - } - - // MARK: - Data sources - - /// Time sessions relevant to the quest: a single action's, or the union of - /// all actions carrying the target tag. - private func timeSessions(for quest: Quest) -> [TimeSession] { - if let action = quest.targetAction { return action.timeSessions } - if let tag = quest.targetTag { return tag.actions.flatMap { $0.timeSessions } } - return [] - } - - /// Count entries relevant to the quest: a single action's, or the union of - /// all actions carrying the target tag. - private func countEntries(for quest: Quest) -> [CountEntry] { - if let action = quest.targetAction { return action.countEntries } - if let tag = quest.targetTag { return tag.actions.flatMap { $0.countEntries } } - return [] - } - - // MARK: - Accumulation - - /// Total tracked duration (seconds) within `[start, end)`. Each session is - /// clipped to the window so boundary-crossing sessions only count their - /// overlapping portion; running sessions count up to `now`. - private func accumulatedTime(for quest: Quest, start: Date, end: Date, now: Date) -> TimeInterval { - timeSessions(for: quest).reduce(0) { total, session in - let sessionEnd = session.endTime ?? now - let overlapStart = max(session.startTime, start) - let overlapEnd = min(sessionEnd, end) - guard overlapEnd > overlapStart else { return total } - return total + overlapEnd.timeIntervalSince(overlapStart) - } - } - - /// Sum of count entry amounts whose timestamp falls within `[start, end)`. - private func accumulatedCount(for quest: Quest, start: Date, end: Date) -> Double { - let entries = countEntries(for: quest) - var total = 0 - for entry in entries where entry.timestamp >= start && entry.timestamp < end { - total += entry.amount - } - return Double(total) - } - - // MARK: - Period bounds - - /// The half-open `[start, end)` window for the current instance of `period`, - /// aligned to the logical day boundary. Returns `nil` for unsupported periods. - private func periodBounds(for period: QuestPeriod, dayStartHour: Int, now: Date) -> (start: Date, end: Date)? { - let calendar = Calendar.current - let dayStart = logicalDayStart(for: now, dayStartHour: dayStartHour, calendar: calendar) - - switch period { - case .daily: - guard let end = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return nil } - return (dayStart, end) - - case .weekly: - // Anchor the week on the calendar day the logical day belongs to, - // then shift by the day-start offset so the week honors the boundary. - let weekComponents = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: dayStart) - guard let weekMidnight = calendar.date(from: weekComponents), - let start = calendar.date(byAdding: .hour, value: dayStartHour, to: weekMidnight), - let end = calendar.date(byAdding: .day, value: 7, to: start) - else { return nil } - return (start, end) - - case .monthly, .custom: - return nil - } - } - - /// Start of the logical day containing `now`: the most recent occurrence of - /// `dayStartHour:00`. If `now` is before today's boundary, the logical day - /// began the previous calendar day. - private func logicalDayStart(for now: Date, dayStartHour: Int, calendar: Calendar) -> Date { - let midnight = calendar.startOfDay(for: now) - let boundary = calendar.date(byAdding: .hour, value: dayStartHour, to: midnight) ?? midnight - if now < boundary { - return calendar.date(byAdding: .day, value: -1, to: boundary) ?? boundary - } - return boundary - } -} diff --git a/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift b/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift deleted file mode 100644 index b3345dd..0000000 --- a/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// TrackingViewModel.swift -// HaruDanim -// -// Drives real-time tracking of Actions: starting/stopping time sessions -// and recording count entries. -// - -import Foundation -import SwiftData -import Observation - -@Observable -final class TrackingViewModel { - /// Currently running time sessions (those without an `endTime`). - var activeSessions: [TimeSession] = [] - - // MARK: - Time tracking - - /// Starts a new `TimeSession` for the action, or ends the running one if it is - /// already being tracked. - func toggleTimeTracking(for action: Action, context: ModelContext) { - if let running = runningSession(for: action, context: context) { - running.endTime = .now - } else { - let session = TimeSession(startTime: .now, action: action) - context.insert(session) - } - save(context) - refreshActiveSessions(context: context) - } - - /// Ends a specific running session (used by the stop button in the UI). - func stopSession(_ session: TimeSession, context: ModelContext) { - guard session.endTime == nil else { return } - session.endTime = .now - save(context) - refreshActiveSessions(context: context) - } - - // MARK: - Count tracking - - /// Records a `CountEntry` for the action. - func incrementCount(for action: Action, amount: Int = 1, context: ModelContext) { - let entry = CountEntry(timestamp: .now, amount: amount, action: action) - context.insert(entry) - save(context) - } - - // MARK: - Today's progress - - /// Sum of today's `CountEntry` amounts for the action (simple calendar day). - func getTodayCount(for action: Action) -> Int { - let calendar = Calendar.current - return action.countEntries - .filter { calendar.isDateInToday($0.timestamp) } - .reduce(0) { $0 + $1.amount } - } - - /// Total tracked duration today for the action, in seconds. Each session is - /// clipped to today's bounds so boundary-crossing sessions count only their - /// portion within the current calendar day. Running sessions count up to now. - func getTodayDuration(for action: Action) -> TimeInterval { - let calendar = Calendar.current - let now = Date.now - let dayStart = calendar.startOfDay(for: now) - let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now - - return action.timeSessions.reduce(0) { total, session in - let sessionEnd = session.endTime ?? now - let overlapStart = max(session.startTime, dayStart) - let overlapEnd = min(sessionEnd, dayEnd) - guard overlapEnd > overlapStart else { return total } - return total + overlapEnd.timeIntervalSince(overlapStart) - } - } - - // MARK: - Active session loading - - /// Reloads `activeSessions` from the store (sessions with no `endTime`). - func refreshActiveSessions(context: ModelContext) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.endTime == nil }, - sortBy: [SortDescriptor(\.startTime, order: .forward)] - ) - activeSessions = (try? context.fetch(descriptor)) ?? [] - } - - /// Returns the running session belonging to `action`, if any. - func runningSession(for action: Action, context: ModelContext) -> TimeSession? { - let actionID = action.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.endTime == nil && $0.action?.id == actionID } - ) - return try? context.fetch(descriptor).first - } - - /// Whether the given action currently has a running session. - func isTracking(_ action: Action) -> Bool { - activeSessions.contains { $0.action?.id == action.id } - } - - // MARK: - Persistence - - private func save(_ context: ModelContext) { - do { - try context.save() - } catch { - // Persistence failures are non-fatal for the in-flight UI; surface in logs. - print("TrackingViewModel save failed: \(error)") - } - } -} diff --git a/myApp/HaruDanim/IOS/Views/ActionTabView.swift b/myApp/HaruDanim/IOS/Views/ActionTabView.swift deleted file mode 100644 index d8a15f8..0000000 --- a/myApp/HaruDanim/IOS/Views/ActionTabView.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// ActionTabView.swift -// HaruDanim -// -// Lists all `Action`s and hosts a sheet for creating new ones. -// - -import SwiftUI -import SwiftData - -struct ActionTabView: View { - @Environment(\.modelContext) private var modelContext - @Query(sort: \Action.createdAt, order: .reverse) private var actions: [Action] - - @State private var isAddSheetPresented = false - - var body: some View { - NavigationStack { - List { - ForEach(actions) { action in - HStack(spacing: 12) { - Image(systemName: action.iconName) - .frame(width: 28) - .foregroundStyle(Color.brandPrimary) - - VStack(alignment: .leading, spacing: 4) { - Text(action.name) - if !action.tags.isEmpty { - Text(action.tags.map(\.name).joined(separator: ", ")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Text(action.type == .time ? "시간" : "횟수") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .onDelete(perform: deleteActions) - } - .navigationTitle("행동") - .overlay { - if actions.isEmpty { - ContentUnavailableView( - "행동이 없어요", - systemImage: "bolt", - description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.") - ) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - isAddSheetPresented = true - } label: { - Label("행동 추가", systemImage: "plus") - } - } - } - .sheet(isPresented: $isAddSheetPresented) { - AddActionSheet() - } - } - } - - private func deleteActions(at offsets: IndexSet) { - for index in offsets { - modelContext.delete(actions[index]) - } - } -} - -// MARK: - Add Action Sheet - -private struct AddActionSheet: View { - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - @Query(sort: \Tag.createdAt, order: .reverse) private var allTags: [Tag] - - @State private var name = "" - @State private var iconName = "star.fill" - @State private var type: ActionType = .time - @State private var selectedTagIDs: Set = [] - - private var trimmedName: String { - name.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var body: some View { - NavigationStack { - Form { - Section("이름") { - TextField("행동 이름", text: $name) - } - - Section("아이콘 (SF Symbol)") { - HStack { - Image(systemName: iconName.isEmpty ? "questionmark" : iconName) - .foregroundStyle(Color.brandPrimary) - TextField("star.fill", text: $iconName) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } - } - - Section("유형") { - Picker("유형", selection: $type) { - Text("시간").tag(ActionType.time) - Text("횟수").tag(ActionType.count) - } - .pickerStyle(.segmented) - } - - Section("꼬리표") { - if allTags.isEmpty { - Text("먼저 꼬리표를 추가하세요.") - .foregroundStyle(.secondary) - } else { - ForEach(allTags) { tag in - Button { - toggle(tag) - } label: { - HStack(spacing: 12) { - Circle() - .fill(Color(hex: tag.colorHex)) - .frame(width: 16, height: 16) - Text(tag.name) - .foregroundStyle(.primary) - Spacer() - if selectedTagIDs.contains(tag.id) { - Image(systemName: "checkmark") - .foregroundStyle(Color.brandPrimary) - } - } - } - } - } - } - } - .navigationTitle("새 행동") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("취소") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("저장") { save() } - .disabled(trimmedName.isEmpty) - } - } - } - } - - private func toggle(_ tag: Tag) { - if selectedTagIDs.contains(tag.id) { - selectedTagIDs.remove(tag.id) - } else { - selectedTagIDs.insert(tag.id) - } - } - - private func save() { - let chosenTags = allTags.filter { selectedTagIDs.contains($0.id) } - let trimmedIcon = iconName.trimmingCharacters(in: .whitespacesAndNewlines) - let action = Action( - name: trimmedName, - type: type, - iconName: trimmedIcon.isEmpty ? "circle" : trimmedIcon, - tags: chosenTags - ) - modelContext.insert(action) - dismiss() - } -} - -#Preview { - ActionTabView() - .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) -} diff --git a/myApp/HaruDanim/IOS/Views/GoalDetailView.swift b/myApp/HaruDanim/IOS/Views/GoalDetailView.swift deleted file mode 100644 index 63605df..0000000 --- a/myApp/HaruDanim/IOS/Views/GoalDetailView.swift +++ /dev/null @@ -1,401 +0,0 @@ -// -// GoalDetailView.swift -// HaruDanim -// -// Shows a `Goal`'s details and its `Quest`s, and hosts a sheet for -// creating new quests. Progress calculation is intentionally omitted. -// - -import SwiftUI -import SwiftData - -struct GoalDetailView: View { - @Environment(\.modelContext) private var modelContext - @Bindable var goal: Goal - - /// App settings drive the logical day boundary used for progress windows. - @Query private var appSettings: [AppSettings] - - @State private var viewModel = GoalViewModel() - @State private var isAddQuestPresented = false - - /// Hour at which a logical day begins (defaults to midnight). - private var dayStartHour: Int { - appSettings.first?.dayStartHour ?? 0 - } - - private var dateFormatter: Date.FormatStyle { - .dateTime.year().month().day() - } - - /// Quests sorted newest first for stable display order. - private var sortedQuests: [Quest] { - goal.quests.sorted { $0.createdAt > $1.createdAt } - } - - var body: some View { - List { - Section("목표") { - HStack(spacing: 12) { - Circle() - .fill(Color(hex: goal.colorHex)) - .frame(width: 20, height: 20) - Text(goal.title) - Spacer() - } - LabeledContent("시작일", value: goal.startDate.formatted(dateFormatter)) - LabeledContent("종료일", value: goal.endDate.formatted(dateFormatter)) - } - - Section("다짐") { - if sortedQuests.isEmpty { - Text("아직 다짐이 없어요.") - .foregroundStyle(.secondary) - } else { - ForEach(sortedQuests) { quest in - QuestRow(quest: quest, viewModel: viewModel, dayStartHour: dayStartHour) - } - .onDelete(perform: deleteQuests) - } - } - } - .navigationTitle(goal.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - isAddQuestPresented = true - } label: { - Label("다짐 추가", systemImage: "plus") - } - } - } - .sheet(isPresented: $isAddQuestPresented) { - AddQuestSheet(goal: goal) - } - } - - private func deleteQuests(at offsets: IndexSet) { - let toDelete = offsets.map { sortedQuests[$0] } - for quest in toDelete { - modelContext.delete(quest) - } - } -} - -// MARK: - Quest Row - -private struct QuestRow: View { - let quest: Quest - let viewModel: GoalViewModel - let dayStartHour: Int - - private var progress: QuestProgress { - viewModel.progress(for: quest, dayStartHour: dayStartHour) - } - - private var targetName: String { - quest.targetAction?.name ?? quest.targetTag?.name ?? "대상 없음" - } - - private var targetIcon: String { - if quest.targetAction != nil { return "bolt" } - if quest.targetTag != nil { return "tag" } - return "questionmark" - } - - var body: some View { - let progress = self.progress - let evaluation = progress.evaluation - - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Image(systemName: targetIcon) - .frame(width: 24) - .foregroundStyle(Color.brandPrimary) - - VStack(alignment: .leading, spacing: 4) { - Text(quest.title.isEmpty ? targetName : quest.title) - Text("\(quest.period.displayName) · \(targetName) \(QuestFormatting.value(quest.targetValue, usesTime: quest.usesTime)) \(quest.direction.displayName)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: evaluation.iconName) - .foregroundStyle(evaluation.tint) - .imageScale(.large) - } - - if evaluation == .unsupported { - Text("이 주기는 아직 진행률을 계산하지 않아요.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ProgressView(value: progress.fraction) - .tint(evaluation.tint) - - HStack { - Text("\(QuestFormatting.amount(progress.current, usesTime: progress.usesTime)) / \(QuestFormatting.amount(progress.target, usesTime: progress.usesTime))") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - Spacer() - Text(evaluation.label) - .font(.caption.weight(.medium)) - .foregroundStyle(evaluation.tint) - } - } - } - .padding(.vertical, 4) - } -} - -// MARK: - Add Quest Sheet - -private struct AddQuestSheet: View { - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - let goal: Goal - - @Query(sort: \Action.createdAt, order: .reverse) private var actions: [Action] - @Query(sort: \Tag.createdAt, order: .reverse) private var tags: [Tag] - - /// Which kind of entity the quest targets. - private enum TargetKind: String, CaseIterable, Identifiable { - case action - case tag - var id: String { rawValue } - var displayName: String { self == .action ? "행동" : "꼬리표" } - } - - @State private var title = "" - @State private var targetKind: TargetKind = .action - @State private var selectedActionID: UUID? - @State private var selectedTagID: UUID? - @State private var period: QuestPeriod = .daily - @State private var direction: QuestDirection = .above - /// Measurement type used when targeting a tag (actions derive it from the action). - @State private var tagMeasureType: ActionType = .time - /// Amount entered by the user: minutes when time-based, units when count-based. - @State private var amount: Double = 0 - - private var selectedAction: Action? { - actions.first { $0.id == selectedActionID } - } - - private var selectedTag: Tag? { - tags.first { $0.id == selectedTagID } - } - - /// Whether the target is measured in time (vs. counts). - private var usesTime: Bool { - switch targetKind { - case .action: return selectedAction?.type == .time - case .tag: return tagMeasureType == .time - } - } - - private var hasTarget: Bool { - switch targetKind { - case .action: return selectedAction != nil - case .tag: return selectedTag != nil - } - } - - private var isValid: Bool { - hasTarget && amount > 0 - } - - var body: some View { - NavigationStack { - Form { - Section("이름 (선택)") { - TextField("다짐 이름", text: $title) - } - - Section("대상") { - Picker("종류", selection: $targetKind) { - ForEach(TargetKind.allCases) { kind in - Text(kind.displayName).tag(kind) - } - } - .pickerStyle(.segmented) - - switch targetKind { - case .action: - if actions.isEmpty { - Text("먼저 행동을 추가하세요.") - .foregroundStyle(.secondary) - } else { - Picker("행동", selection: $selectedActionID) { - Text("선택 안 함").tag(UUID?.none) - ForEach(actions) { action in - Text(action.name).tag(UUID?.some(action.id)) - } - } - } - case .tag: - if tags.isEmpty { - Text("먼저 꼬리표를 추가하세요.") - .foregroundStyle(.secondary) - } else { - Picker("꼬리표", selection: $selectedTagID) { - Text("선택 안 함").tag(UUID?.none) - ForEach(tags) { tag in - Text(tag.name).tag(UUID?.some(tag.id)) - } - } - Picker("측정", selection: $tagMeasureType) { - Text("시간").tag(ActionType.time) - Text("횟수").tag(ActionType.count) - } - .pickerStyle(.segmented) - } - } - } - - Section("주기") { - Picker("주기", selection: $period) { - Text("매일").tag(QuestPeriod.daily) - Text("매주").tag(QuestPeriod.weekly) - Text("매월").tag(QuestPeriod.monthly) - } - .pickerStyle(.segmented) - } - - Section(usesTime ? "목표 시간 (분)" : "목표 횟수") { - TextField(usesTime ? "분" : "횟수", value: $amount, format: .number) - .keyboardType(.numberPad) - } - - Section("방향") { - Picker("방향", selection: $direction) { - Text("이상").tag(QuestDirection.above) - Text("이하").tag(QuestDirection.below) - } - .pickerStyle(.segmented) - } - } - .navigationTitle("새 다짐") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("취소") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("저장") { save() } - .disabled(!isValid) - } - } - } - } - - private func save() { - // Time targets are entered in minutes but stored in seconds. - let targetValue = usesTime ? amount * 60 : amount - let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - let quest = Quest( - title: trimmedTitle, - period: period, - direction: direction, - targetValue: targetValue, - usesTime: usesTime, - goal: goal, - targetAction: targetKind == .action ? selectedAction : nil, - targetTag: targetKind == .tag ? selectedTag : nil - ) - modelContext.insert(quest) - dismiss() - } -} - -// MARK: - Display Helpers - -private enum QuestFormatting { - /// Formats a stored `targetValue` for display: minutes for time, units for count. - static func value(_ value: Double, usesTime: Bool) -> String { - if usesTime { - let minutes = Int((value / 60).rounded()) - return "\(minutes)분" - } - return "\(Int(value.rounded()))회" - } - - /// Formats an accumulated amount for progress display. Time is shown as - /// hours + minutes once it reaches an hour, otherwise minutes; counts as 회. - static func amount(_ value: Double, usesTime: Bool) -> String { - guard usesTime else { return "\(Int(value.rounded()))회" } - - let totalMinutes = Int((value / 60).rounded()) - if totalMinutes >= 60 { - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - return minutes == 0 ? "\(hours)시간" : "\(hours)시간 \(minutes)분" - } - return "\(totalMinutes)분" - } -} - -private extension QuestEvaluation { - /// Accent yellow (`#D9A621`) used for in-progress states. - private static let accent = Color(hex: "#D9A621") - - var label: String { - switch self { - case .achieved: return "달성" - case .inProgress: return "진행 중" - case .onTrack: return "유지 중" - case .failed: return "초과" - case .unsupported: return "" - } - } - - var iconName: String { - switch self { - case .achieved: return "checkmark.circle.fill" - case .inProgress: return "hourglass" - case .onTrack: return "checkmark.shield.fill" - case .failed: return "exclamationmark.triangle.fill" - case .unsupported: return "questionmark.circle" - } - } - - var tint: Color { - switch self { - case .achieved, .onTrack: return .brandPrimary - case .inProgress: return Self.accent - case .failed: return .red - case .unsupported: return .secondary - } - } -} - -private extension QuestPeriod { - var displayName: String { - switch self { - case .daily: return "매일" - case .weekly: return "매주" - case .monthly: return "매월" - case .custom: return "사용자" - } - } -} - -private extension QuestDirection { - var displayName: String { - switch self { - case .above: return "이상" - case .below: return "이하" - } - } -} - -#Preview { - NavigationStack { - GoalDetailView(goal: Goal(title: "미리보기 목표")) - } - .modelContainer(for: [Goal.self, Quest.self, Action.self, Tag.self], inMemory: true) -} diff --git a/myApp/HaruDanim/IOS/Views/GoalTabView.swift b/myApp/HaruDanim/IOS/Views/GoalTabView.swift deleted file mode 100644 index ad1cd75..0000000 --- a/myApp/HaruDanim/IOS/Views/GoalTabView.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// GoalTabView.swift -// HaruDanim -// -// Lists all `Goal`s and hosts a sheet for creating new ones. -// - -import SwiftUI -import SwiftData - -struct GoalTabView: View { - @Environment(\.modelContext) private var modelContext - @Query(sort: \Goal.createdAt, order: .reverse) private var goals: [Goal] - - @State private var isAddSheetPresented = false - - private var dateRangeFormatter: Date.FormatStyle { - .dateTime.year().month().day() - } - - var body: some View { - NavigationStack { - List { - ForEach(goals) { goal in - NavigationLink { - GoalDetailView(goal: goal) - } label: { - HStack(spacing: 12) { - Circle() - .fill(Color(hex: goal.colorHex)) - .frame(width: 20, height: 20) - - VStack(alignment: .leading, spacing: 4) { - Text(goal.title) - Text("\(goal.startDate.formatted(dateRangeFormatter)) ~ \(goal.endDate.formatted(dateRangeFormatter))") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Text("다짐 \(goal.quests.count)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .onDelete(perform: deleteGoals) - } - .navigationTitle("목표") - .overlay { - if goals.isEmpty { - ContentUnavailableView( - "목표가 없어요", - systemImage: "flag", - description: Text("오른쪽 위 + 버튼으로 목표를 추가하세요.") - ) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - isAddSheetPresented = true - } label: { - Label("목표 추가", systemImage: "plus") - } - } - } - .sheet(isPresented: $isAddSheetPresented) { - AddGoalSheet() - } - } - } - - private func deleteGoals(at offsets: IndexSet) { - for index in offsets { - modelContext.delete(goals[index]) - } - } -} - -// MARK: - Add Goal Sheet - -private struct AddGoalSheet: View { - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - @State private var title = "" - @State private var color = Color(hex: "#2F6B4F") - @State private var startDate = Date.now - @State private var endDate = Date.now - - private var trimmedTitle: String { - title.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var isValid: Bool { - !trimmedTitle.isEmpty && endDate >= startDate - } - - var body: some View { - NavigationStack { - Form { - Section("이름") { - TextField("목표 이름", text: $title) - } - - Section("색상") { - ColorPicker("색상 선택", selection: $color, supportsOpacity: false) - } - - Section("기간") { - DatePicker("시작일", selection: $startDate, displayedComponents: .date) - DatePicker("종료일", selection: $endDate, in: startDate..., displayedComponents: .date) - } - } - .navigationTitle("새 목표") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("취소") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("저장") { save() } - .disabled(!isValid) - } - } - } - } - - private func save() { - let goal = Goal( - title: trimmedTitle, - colorHex: color.toHex(), - startDate: startDate, - endDate: endDate - ) - modelContext.insert(goal) - dismiss() - } -} - -#Preview { - GoalTabView() - .modelContainer(for: [Goal.self, Quest.self, Action.self, Tag.self], inMemory: true) -} diff --git a/myApp/HaruDanim/IOS/Views/MainDashboardView.swift b/myApp/HaruDanim/IOS/Views/MainDashboardView.swift deleted file mode 100644 index c03c06c..0000000 --- a/myApp/HaruDanim/IOS/Views/MainDashboardView.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// MainDashboardView.swift -// HaruDanim -// -// The main dashboard: shows currently running time sessions and a grid of -// Actions the user can tap to start/stop tracking or add a count. -// - -import SwiftUI -import SwiftData - -struct MainDashboardView: View { - @Environment(\.modelContext) private var context - @Query(sort: \Action.createdAt, order: .forward) private var actions: [Action] - - @State private var viewModel = TrackingViewModel() - - private let columns = [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ] - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - activeSection - actionsSection - } - .padding() - } - .navigationTitle("메인") - .onAppear { viewModel.refreshActiveSessions(context: context) } - } - } - - // MARK: - Currently tracking - - @ViewBuilder - private var activeSection: some View { - if !viewModel.activeSessions.isEmpty { - VStack(alignment: .leading, spacing: 12) { - Text("현재 진행 중") - .font(.headline) - - ForEach(viewModel.activeSessions) { session in - ActiveSessionRow(session: session) { - viewModel.stopSession(session, context: context) - } - } - } - } - } - - // MARK: - Actions grid - - @ViewBuilder - private var actionsSection: some View { - if actions.isEmpty { - Text("등록된 행동이 없습니다.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 40) - } else { - LazyVGrid(columns: columns, spacing: 12) { - ForEach(actions) { action in - ActionTile( - action: action, - isTracking: viewModel.isTracking(action), - todayCount: viewModel.getTodayCount(for: action), - todayDuration: viewModel.getTodayDuration(for: action) - ) { - tapped(action) - } - } - } - } - } - - private func tapped(_ action: Action) { - switch action.type { - case .time: - viewModel.toggleTimeTracking(for: action, context: context) - case .count: - viewModel.incrementCount(for: action, context: context) - } - } -} - -// MARK: - Active session row - -private struct ActiveSessionRow: View { - let session: TimeSession - let onStop: () -> Void - - var body: some View { - HStack(spacing: 12) { - Image(systemName: session.action?.iconName ?? "circle") - .font(.title3) - .foregroundStyle(Color.brandPrimary) - - VStack(alignment: .leading, spacing: 2) { - Text(session.action?.name ?? "행동") - .font(.subheadline.weight(.semibold)) - TimelineView(.periodic(from: session.startTime, by: 1)) { _ in - Text(elapsedString) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Button(action: onStop) { - Image(systemName: "stop.fill") - .font(.title3) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(Color.brandPrimary, in: Circle()) - } - .buttonStyle(.plain) - } - .padding(12) - .background(Color.brandPrimary.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) - } - - private var elapsedString: String { - let seconds = Int(Date.now.timeIntervalSince(session.startTime)) - let h = seconds / 3600 - let m = (seconds % 3600) / 60 - let s = seconds % 60 - return String(format: "%02d:%02d:%02d", h, m, s) - } -} - -// MARK: - Action tile - -private struct ActionTile: View { - let action: Action - let isTracking: Bool - let todayCount: Int - let todayDuration: TimeInterval - let onTap: () -> Void - - private var tileColor: Color { - Color(hex: action.tags.first?.colorHex ?? "#2F6B4F") - } - - /// Today's progress line, formatted per action type. - private var progressText: String { - switch action.type { - case .count: - return "\(todayCount)회" - case .time: - return Self.durationString(from: todayDuration) - } - } - - var body: some View { - Button(action: onTap) { - VStack(spacing: 10) { - Image(systemName: action.iconName) - .font(.system(size: 30)) - Text(action.name) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - Text(progressText) - .font(.caption) - .foregroundStyle(.white.opacity(0.85)) - if action.type == .time && isTracking { - Text("진행 중") - .font(.caption2.weight(.bold)) - } - } - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 120) - .background(tileColor, in: RoundedRectangle(cornerRadius: 18)) - .overlay { - if isTracking { - RoundedRectangle(cornerRadius: 18) - .strokeBorder(.white, lineWidth: 3) - } - } - } - .buttonStyle(.plain) - } - - /// Formats a duration concisely, e.g. `1h 20m`, `45m`, or `0m`. - private static func durationString(from interval: TimeInterval) -> String { - let totalMinutes = Int(interval) / 60 - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - if hours > 0 { - return "\(hours)h \(minutes)m" - } - return "\(minutes)m" - } -} - -// MARK: - Color helpers - -extension Color { - /// App primary green (adapts light/dark via asset-free literal fallback). - static let brandPrimary = Color(hex: "#2F6B4F") - - /// Builds a `Color` from a `#RRGGBB` (or `RRGGBB`) hex string. - init(hex: String) { - let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var value: UInt64 = 0 - Scanner(string: cleaned).scanHexInt64(&value) - - let r, g, b, a: Double - switch cleaned.count { - case 6: - r = Double((value & 0xFF0000) >> 16) / 255 - g = Double((value & 0x00FF00) >> 8) / 255 - b = Double(value & 0x0000FF) / 255 - a = 1 - case 8: - r = Double((value & 0xFF000000) >> 24) / 255 - g = Double((value & 0x00FF0000) >> 16) / 255 - b = Double((value & 0x0000FF00) >> 8) / 255 - a = Double(value & 0x000000FF) / 255 - default: - r = 0.18; g = 0.42; b = 0.31; a = 1 // fallback to primary green - } - self.init(.sRGB, red: r, green: g, blue: b, opacity: a) - } -} - -#Preview { - MainDashboardView() - .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) -} diff --git a/myApp/HaruDanim/IOS/Views/MainTabView.swift b/myApp/HaruDanim/IOS/Views/MainTabView.swift deleted file mode 100644 index 58a8246..0000000 --- a/myApp/HaruDanim/IOS/Views/MainTabView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// MainTabView.swift -// HaruDanim -// -// Root tab navigation hosting the app's six top-level sections. -// - -import SwiftUI - -struct MainTabView: View { - var body: some View { - TabView { - MainDashboardView() - .tabItem { - Label("메인", systemImage: "house") - } - - ActionTabView() - .tabItem { - Label("행동", systemImage: "bolt") - } - - TagTabView() - .tabItem { - Label("꼬리표", systemImage: "tag") - } - - GoalTabView() - .tabItem { - Label("목표", systemImage: "flag") - } - - RecordTabView() - .tabItem { - Label("기록", systemImage: "chart.bar") - } - - Text("설정") - .tabItem { - Label("설정", systemImage: "gearshape") - } - } - } -} - -#Preview { - MainTabView() -} diff --git a/myApp/HaruDanim/IOS/Views/RecordTabView.swift b/myApp/HaruDanim/IOS/Views/RecordTabView.swift deleted file mode 100644 index c9f581c..0000000 --- a/myApp/HaruDanim/IOS/Views/RecordTabView.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// RecordTabView.swift -// HaruDanim -// -// Shows the tracking records (`TimeSession`s and `CountEntry`s) that occurred -// on a user-selected date, as a single chronological list. -// - -import SwiftUI -import SwiftData - -struct RecordTabView: View { - @Query(sort: \TimeSession.startTime, order: .reverse) private var sessions: [TimeSession] - @Query(sort: \CountEntry.timestamp, order: .reverse) private var counts: [CountEntry] - - /// The day whose records are shown. Defaults to today. - @State private var selectedDate: Date = .now - - /// Records that fall on `selectedDate`, merged and sorted newest first. - private var recordsForDay: [RecordItem] { - let calendar = Calendar.current - - let sessionItems = sessions - .filter { calendar.isDate($0.startTime, inSameDayAs: selectedDate) } - .map { RecordItem.session($0) } - - let countItems = counts - .filter { calendar.isDate($0.timestamp, inSameDayAs: selectedDate) } - .map { RecordItem.count($0) } - - return (sessionItems + countItems).sorted { $0.sortTime > $1.sortTime } - } - - var body: some View { - NavigationStack { - List { - Section { - DatePicker( - "날짜", - selection: $selectedDate, - displayedComponents: .date - ) - } - - Section("기록") { - if recordsForDay.isEmpty { - Text("이 날의 기록이 없어요.") - .foregroundStyle(.secondary) - } else { - ForEach(recordsForDay) { item in - switch item { - case .session(let session): - TimeSessionRow(session: session) - case .count(let entry): - CountEntryRow(entry: entry) - } - } - } - } - } - .navigationTitle("기록") - } - } -} - -// MARK: - Record Item - -/// A single tracking record on a given day, either time- or count-based. -private enum RecordItem: Identifiable { - case session(TimeSession) - case count(CountEntry) - - var id: UUID { - switch self { - case .session(let session): return session.id - case .count(let entry): return entry.id - } - } - - /// The time used to order records within a day. - var sortTime: Date { - switch self { - case .session(let session): return session.startTime - case .count(let entry): return entry.timestamp - } - } -} - -// MARK: - Rows - -private struct TimeSessionRow: View { - let session: TimeSession - - var body: some View { - HStack(spacing: 12) { - TagColorDot(colorHex: session.action?.tags.first?.colorHex) - - VStack(alignment: .leading, spacing: 4) { - Text(session.action?.name ?? "알 수 없는 행동") - - HStack(spacing: 4) { - Text(session.startTime.formatted(date: .omitted, time: .shortened)) - Text("–") - if let endTime = session.endTime { - Text(endTime.formatted(date: .omitted, time: .shortened)) - } else { - Text("진행 중") - } - } - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Text(durationText) - .font(.caption.monospacedDigit()) - .foregroundStyle(Color.brandPrimary) - } - } - - /// Formatted elapsed time, or a running indicator when the session is open. - private var durationText: String { - guard let endTime = session.endTime else { return "진행 중" } - return RecordFormatting.duration(from: session.startTime, to: endTime) - } -} - -private struct CountEntryRow: View { - let entry: CountEntry - - var body: some View { - HStack(spacing: 12) { - TagColorDot(colorHex: entry.action?.tags.first?.colorHex) - - VStack(alignment: .leading, spacing: 4) { - Text(entry.action?.name ?? "알 수 없는 행동") - - Text(entry.timestamp.formatted(date: .omitted, time: .standard)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Text("\(entry.amount)회") - .font(.caption.monospacedDigit()) - .foregroundStyle(Color.brandPrimary) - } - } -} - -// MARK: - Tag Color Dot - -private struct TagColorDot: View { - let colorHex: String? - - var body: some View { - Circle() - .fill(colorHex.map { Color(hex: $0) } ?? Color.secondary.opacity(0.3)) - .frame(width: 16, height: 16) - } -} - -// MARK: - Display Helpers - -private enum RecordFormatting { - /// Formats an interval as `H시간 M분` (hours dropped when zero, at least `0분`). - static func duration(from start: Date, to end: Date) -> String { - let totalSeconds = max(0, Int(end.timeIntervalSince(start))) - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - if hours > 0 { - return "\(hours)시간 \(minutes)분" - } - return "\(minutes)분" - } -} - -#Preview { - RecordTabView() - .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) -} diff --git a/myApp/HaruDanim/IOS/Views/TagTabView.swift b/myApp/HaruDanim/IOS/Views/TagTabView.swift deleted file mode 100644 index 6c6458c..0000000 --- a/myApp/HaruDanim/IOS/Views/TagTabView.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// TagTabView.swift -// HaruDanim -// -// Lists all `Tag`s and hosts a sheet for creating new ones. -// - -import SwiftUI -import SwiftData - -struct TagTabView: View { - @Environment(\.modelContext) private var modelContext - @Query(sort: \Tag.createdAt, order: .reverse) private var tags: [Tag] - - @State private var isAddSheetPresented = false - - var body: some View { - NavigationStack { - List { - ForEach(tags) { tag in - HStack(spacing: 12) { - Circle() - .fill(Color(hex: tag.colorHex)) - .frame(width: 20, height: 20) - Text(tag.name) - Spacer() - } - } - .onDelete(perform: deleteTags) - } - .navigationTitle("꼬리표") - .overlay { - if tags.isEmpty { - ContentUnavailableView( - "꼬리표가 없어요", - systemImage: "tag", - description: Text("오른쪽 위 + 버튼으로 꼬리표를 추가하세요.") - ) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - isAddSheetPresented = true - } label: { - Label("꼬리표 추가", systemImage: "plus") - } - } - } - .sheet(isPresented: $isAddSheetPresented) { - AddTagSheet() - } - } - } - - private func deleteTags(at offsets: IndexSet) { - for index in offsets { - modelContext.delete(tags[index]) - } - } -} - -// MARK: - Add Tag Sheet - -private struct AddTagSheet: View { - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - @State private var name = "" - @State private var color = Color(hex: "#2F6B4F") - - private var trimmedName: String { - name.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var body: some View { - NavigationStack { - Form { - Section("이름") { - TextField("꼬리표 이름", text: $name) - } - - Section("색상") { - ColorPicker("색상 선택", selection: $color, supportsOpacity: false) - } - } - .navigationTitle("새 꼬리표") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("취소") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("저장") { save() } - .disabled(trimmedName.isEmpty) - } - } - } - } - - private func save() { - let tag = Tag(name: trimmedName, colorHex: color.toHex()) - modelContext.insert(tag) - dismiss() - } -} - -// MARK: - Color → hex helper - -extension Color { - /// Serializes the color to a `#RRGGBB` string for persistence. - func toHex() -> String { - let resolved = resolve(in: EnvironmentValues()) - return String( - format: "#%02X%02X%02X", - Int((resolved.red * 255).rounded()), - Int((resolved.green * 255).rounded()), - Int((resolved.blue * 255).rounded()) - ) - } -} - -#Preview { - TagTabView() - .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) -}