remove old project
This commit is contained in:
parent
7c85cadd8e
commit
2d9e7ad20b
1
myApp/Expiranner
Submodule
1
myApp/Expiranner
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 74a95d489240619326e8abc1725d15b6e62031ab
|
||||
@ -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.
|
||||
@ -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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
72CCE2A92FF7E9A50031A6A4 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72CCE2A82FF7E9A50031A6A4 /* HaruDanim.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
Binary file not shown.
@ -1,14 +0,0 @@
|
||||
<?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>HaruDanim.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
])
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<TimeSession>(
|
||||
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<TimeSession>(
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<UUID> = []
|
||||
|
||||
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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user