new feature Quest(다짐) pogress and Fix an issue in 횟수측정 Action(행동) where count and time are recorded but not reflected in the icon
This commit is contained in:
parent
c35f50fa73
commit
7a53bb9ce4
Binary file not shown.
171
myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift
Normal file
171
myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift
Normal file
@ -0,0 +1,171 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,34 @@ final class TrackingViewModel {
|
||||
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`).
|
||||
|
||||
@ -13,8 +13,17 @@ 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()
|
||||
}
|
||||
@ -44,7 +53,7 @@ struct GoalDetailView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(sortedQuests) { quest in
|
||||
QuestRow(quest: quest)
|
||||
QuestRow(quest: quest, viewModel: viewModel, dayStartHour: dayStartHour)
|
||||
}
|
||||
.onDelete(perform: deleteQuests)
|
||||
}
|
||||
@ -78,6 +87,12 @@ struct GoalDetailView: View {
|
||||
|
||||
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 ?? "대상 없음"
|
||||
@ -90,20 +105,50 @@ private struct QuestRow: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: targetIcon)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(Color.brandPrimary)
|
||||
let progress = self.progress
|
||||
let evaluation = progress.evaluation
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -278,6 +323,54 @@ private enum QuestFormatting {
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -66,7 +66,9 @@ struct MainDashboardView: View {
|
||||
ForEach(actions) { action in
|
||||
ActionTile(
|
||||
action: action,
|
||||
isTracking: viewModel.isTracking(action)
|
||||
isTracking: viewModel.isTracking(action),
|
||||
todayCount: viewModel.getTodayCount(for: action),
|
||||
todayDuration: viewModel.getTodayDuration(for: action)
|
||||
) {
|
||||
tapped(action)
|
||||
}
|
||||
@ -136,12 +138,24 @@ private struct ActiveSessionRow: View {
|
||||
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) {
|
||||
@ -150,6 +164,9 @@ private struct ActionTile: View {
|
||||
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))
|
||||
@ -168,6 +185,17 @@ private struct ActionTile: View {
|
||||
}
|
||||
.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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user