mycode/myApp/HaruDanim/IOS/Views/MainDashboardView.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)
}