236 lines
7.3 KiB
Swift
236 lines
7.3 KiB
Swift
//
|
|
// 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)
|
|
}
|