diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md new file mode 100644 index 0000000..24d0275 --- /dev/null +++ b/myApp/HaruDanim/CLAUDE.md @@ -0,0 +1,22 @@ +# HaruDanim (하루 다님) Development Guidelines + +## Architecture & Stack +- **UI Framework**: SwiftUI +- **Data Storage**: SwiftData +- **Architecture**: MVVM (Model-View-ViewModel) + Observation (`@Observable`) +- **Minimum Target**: iOS 17.0+ (to fully utilize SwiftData and new Observation framework) + +## Coding Conventions +1. **Scope Limitation**: Never scan the entire project unnecessarily. Only read and edit files explicitly requested. +2. **Data Modeling**: + - Use `@Model` macro for all SwiftData models. + - Strictly implement relationships (e.g., Action <-> Tag many-to-many, Goal -> Quest one-to-many). + - Use raw value Enums for specific types (ActionType, QuestPeriod, Direction). +3. **UI/UX Guidelines**: + - Primary Green: Light `#2F6B4F`, Dark `#7FBF9E` + - Accent Yellow: Light `#D9A621`, Dark `#E8C558` + - Background: Light `#FAFAF6`, Dark `#111512` + - Use `SF Symbols` exclusively for iconography. +4. **Business Logic**: + - A day boundary starts at `AppSettings.dayStartHour` (default 00:00). + - Sessions crossing this boundary must be saved as one `TimeSession` but logically split when queried for statistics. diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 4064445..9cb6840 100644 Binary files a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 9d87c46..abc6659 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -9,13 +9,7 @@ import SwiftUI struct ContentView: View { var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() + MainTabView() } } diff --git a/myApp/HaruDanim/IOS/HaruDanimApp.swift b/myApp/HaruDanim/IOS/HaruDanimApp.swift index 003b835..f80e844 100644 --- a/myApp/HaruDanim/IOS/HaruDanimApp.swift +++ b/myApp/HaruDanim/IOS/HaruDanimApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SwiftData @main struct HaruDanimApp: App { @@ -13,5 +14,14 @@ struct HaruDanimApp: App { WindowGroup { ContentView() } + .modelContainer(for: [ + Action.self, + Tag.self, + TimeSession.self, + CountEntry.self, + Goal.self, + Quest.self, + AppSettings.self + ]) } } diff --git a/myApp/HaruDanim/IOS/Models/Schema.swift b/myApp/HaruDanim/IOS/Models/Schema.swift new file mode 100644 index 0000000..f46590f --- /dev/null +++ b/myApp/HaruDanim/IOS/Models/Schema.swift @@ -0,0 +1,265 @@ +// +// 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 + 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, + createdAt: Date = .now, + quests: [Quest] = [] + ) { + self.id = id + self.title = title + self.statusRaw = status.rawValue + 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 + var createdAt: Date + + /// The goal this quest belongs to (inverse of `Goal.quests`). + var goal: Goal? + + 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, + createdAt: Date = .now, + goal: Goal? = nil + ) { + self.id = id + self.title = title + self.periodRaw = period.rawValue + self.directionRaw = direction.rawValue + self.targetValue = targetValue + self.createdAt = createdAt + self.goal = goal + } +} + +// MARK: - AppSettings + +@Model +final class AppSettings { + @Attribute(.unique) var id: UUID + /// Hour (0...23) at which a new logical day begins. Default 00:00. + var dayStartHour: Int + + init( + id: UUID = UUID(), + dayStartHour: Int = 0 + ) { + self.id = id + self.dayStartHour = dayStartHour + } +} diff --git a/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift b/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift new file mode 100644 index 0000000..d03e71b --- /dev/null +++ b/myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift @@ -0,0 +1,85 @@ +// +// 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: - Active session loading + + /// Reloads `activeSessions` from the store (sessions with no `endTime`). + func refreshActiveSessions(context: ModelContext) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.endTime == nil }, + sortBy: [SortDescriptor(\.startTime, order: .forward)] + ) + activeSessions = (try? context.fetch(descriptor)) ?? [] + } + + /// Returns the running session belonging to `action`, if any. + func runningSession(for action: Action, context: ModelContext) -> TimeSession? { + let actionID = action.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.endTime == nil && $0.action?.id == actionID } + ) + return try? context.fetch(descriptor).first + } + + /// Whether the given action currently has a running session. + func isTracking(_ action: Action) -> Bool { + activeSessions.contains { $0.action?.id == action.id } + } + + // MARK: - Persistence + + private func save(_ context: ModelContext) { + do { + try context.save() + } catch { + // Persistence failures are non-fatal for the in-flight UI; surface in logs. + print("TrackingViewModel save failed: \(error)") + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/MainDashboardView.swift b/myApp/HaruDanim/IOS/Views/MainDashboardView.swift new file mode 100644 index 0000000..db9c3ea --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/MainDashboardView.swift @@ -0,0 +1,207 @@ +// +// 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) + ) { + 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 onTap: () -> Void + + private var tileColor: Color { + Color(hex: action.tags.first?.colorHex ?? "#2F6B4F") + } + + 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) + 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) + } +} + +// MARK: - Color helpers + +extension Color { + /// App primary green (adapts light/dark via asset-free literal fallback). + static let brandPrimary = Color(hex: "#2F6B4F") + + /// Builds a `Color` from a `#RRGGBB` (or `RRGGBB`) hex string. + init(hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + + let r, g, b, a: Double + switch cleaned.count { + case 6: + r = Double((value & 0xFF0000) >> 16) / 255 + g = Double((value & 0x00FF00) >> 8) / 255 + b = Double(value & 0x0000FF) / 255 + a = 1 + case 8: + r = Double((value & 0xFF000000) >> 24) / 255 + g = Double((value & 0x00FF0000) >> 16) / 255 + b = Double((value & 0x0000FF00) >> 8) / 255 + a = Double(value & 0x000000FF) / 255 + default: + r = 0.18; g = 0.42; b = 0.31; a = 1 // fallback to primary green + } + self.init(.sRGB, red: r, green: g, blue: b, opacity: a) + } +} + +#Preview { + MainDashboardView() + .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) +} diff --git a/myApp/HaruDanim/IOS/Views/MainTabView.swift b/myApp/HaruDanim/IOS/Views/MainTabView.swift new file mode 100644 index 0000000..b9480c4 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/MainTabView.swift @@ -0,0 +1,48 @@ +// +// 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") + } + + Text("행동") + .tabItem { + Label("행동", systemImage: "bolt") + } + + Text("꼬리표") + .tabItem { + Label("꼬리표", systemImage: "tag") + } + + Text("목표") + .tabItem { + Label("목표", systemImage: "flag") + } + + Text("기록") + .tabItem { + Label("기록", systemImage: "chart.bar") + } + + Text("설정") + .tabItem { + Label("설정", systemImage: "gearshape") + } + } + } +} + +#Preview { + MainTabView() +}