Setting claude code and basic layout

This commit is contained in:
songyc macbook 2026-07-06 00:56:00 +09:00
parent 9aa5cc5414
commit 52f45dba2b
8 changed files with 638 additions and 7 deletions

22
myApp/HaruDanim/CLAUDE.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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