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