remove old project

This commit is contained in:
songyc macbook 2026-07-09 19:07:01 +09:00
parent 7c85cadd8e
commit 2d9e7ad20b
21 changed files with 1 additions and 2369 deletions

1
myApp/Expiranner Submodule

@ -0,0 +1 @@
Subproject commit 74a95d489240619326e8abc1725d15b6e62031ab

View File

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

View File

@ -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 */;
}

View File

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

View File

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

View File

@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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