243 lines
9.3 KiB
Swift
243 lines
9.3 KiB
Swift
// ── History View ───────────────────────────────────────────────
|
|
// Past timers, stats, streaks
|
|
|
|
import SwiftUI
|
|
|
|
struct HistoryView: View {
|
|
@EnvironmentObject var store: TimerStore
|
|
@State private var selectedSegment: HistorySegment = .recent
|
|
|
|
enum HistorySegment: String, CaseIterable {
|
|
case recent = "Recent"
|
|
case stats = "Stats"
|
|
}
|
|
|
|
private var completedTimers: [CMTimer] {
|
|
store.timers
|
|
.filter { [.completed, .dismissed].contains($0.state) }
|
|
.sorted { ($0.completedAt ?? $0.dismissedAt ?? .distantPast) > ($1.completedAt ?? $1.dismissedAt ?? .distantPast) }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
CMColors.bg.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
// Segment picker
|
|
Picker("View", selection: $selectedSegment) {
|
|
ForEach(HistorySegment.allCases, id: \.self) { segment in
|
|
Text(segment.rawValue).tag(segment)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, CMSpacing.lg)
|
|
.padding(.vertical, CMSpacing.md)
|
|
|
|
switch selectedSegment {
|
|
case .recent:
|
|
recentList
|
|
case .stats:
|
|
statsView
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("History")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent List
|
|
|
|
private var recentList: some View {
|
|
Group {
|
|
if completedTimers.isEmpty {
|
|
VStack(spacing: CMSpacing.lg) {
|
|
Spacer()
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.font(.system(size: 48))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
Text("No timer history yet")
|
|
.font(CMFonts.body(size: 18, weight: .semibold))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
Text("Completed and dismissed timers\nwill appear here")
|
|
.font(CMFonts.body(size: 14))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
.multilineTextAlignment(.center)
|
|
Spacer()
|
|
}
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: CMSpacing.md) {
|
|
ForEach(completedTimers) { timer in
|
|
HistoryCard(timer: timer)
|
|
}
|
|
}
|
|
.padding(.horizontal, CMSpacing.lg)
|
|
.padding(.bottom, CMSpacing.xxl)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Stats View
|
|
|
|
private var statsView: some View {
|
|
ScrollView {
|
|
VStack(spacing: CMSpacing.lg) {
|
|
// Summary cards
|
|
let allTimers = store.timers
|
|
let completed = allTimers.filter { $0.state == .completed }.count
|
|
let dismissed = allTimers.filter { $0.state == .dismissed }.count
|
|
let active = store.activeTimers.count
|
|
let totalSnoozes = allTimers.reduce(0) { $0 + $1.snoozeCount }
|
|
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible(), spacing: CMSpacing.md),
|
|
GridItem(.flexible(), spacing: CMSpacing.md),
|
|
], spacing: CMSpacing.md) {
|
|
StatCard(title: "Completed", value: "\(completed)", icon: "checkmark.circle.fill", color: CMColors.success)
|
|
StatCard(title: "Dismissed", value: "\(dismissed)", icon: "xmark.circle.fill", color: CMColors.textMuted)
|
|
StatCard(title: "Active", value: "\(active)", icon: "play.circle.fill", color: CMColors.accent)
|
|
StatCard(title: "Snoozes", value: "\(totalSnoozes)", icon: "moon.zzz.fill", color: CMColors.important)
|
|
}
|
|
|
|
// On-time rate
|
|
if completed + dismissed > 0 {
|
|
let onTimeRate = Double(completed) / Double(completed + dismissed) * 100
|
|
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
|
HStack {
|
|
Text("Completion Rate")
|
|
.font(CMFonts.body(size: 14, weight: .medium))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
Spacer()
|
|
Text(String(format: "%.0f%%", onTimeRate))
|
|
.font(CMFonts.mono(size: 20, weight: .bold))
|
|
.foregroundStyle(onTimeRate >= 70 ? CMColors.success : CMColors.important)
|
|
}
|
|
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(CMColors.border)
|
|
.frame(height: 8)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(onTimeRate >= 70 ? CMColors.success : CMColors.important)
|
|
.frame(width: geo.size.width * (onTimeRate / 100), height: 8)
|
|
}
|
|
}
|
|
.frame(height: 8)
|
|
}
|
|
.padding(CMSpacing.lg)
|
|
.background(CMColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
|
}
|
|
|
|
// Timer type breakdown
|
|
let typeBreakdown = Dictionary(grouping: allTimers, by: { $0.type })
|
|
VStack(alignment: .leading, spacing: CMSpacing.md) {
|
|
Text("By Type")
|
|
.font(CMFonts.body(size: 14, weight: .medium))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
|
|
ForEach(CMTimerType.allCases) { type in
|
|
let count = typeBreakdown[type]?.count ?? 0
|
|
if count > 0 {
|
|
HStack {
|
|
Text(type.label)
|
|
.font(CMFonts.body(size: 14))
|
|
.foregroundStyle(CMColors.text)
|
|
Spacer()
|
|
Text("\(count)")
|
|
.font(CMFonts.mono(size: 14, weight: .semibold))
|
|
.foregroundStyle(CMColors.accent)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(CMSpacing.lg)
|
|
.background(CMColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
|
}
|
|
.padding(.horizontal, CMSpacing.lg)
|
|
.padding(.bottom, CMSpacing.xxl)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Stat Card
|
|
|
|
struct StatCard: View {
|
|
let title: String
|
|
let value: String
|
|
let icon: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(spacing: CMSpacing.sm) {
|
|
Image(systemName: icon)
|
|
.font(.title2)
|
|
.foregroundStyle(color)
|
|
|
|
Text(value)
|
|
.font(CMFonts.mono(size: 28, weight: .bold))
|
|
.foregroundStyle(CMColors.text)
|
|
|
|
Text(title)
|
|
.font(CMFonts.body(size: 12, weight: .medium))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(CMSpacing.lg)
|
|
.background(CMColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: CMRadius.md)
|
|
.stroke(CMColors.border, lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - History Card
|
|
|
|
struct HistoryCard: View {
|
|
let timer: CMTimer
|
|
|
|
var body: some View {
|
|
HStack(spacing: CMSpacing.md) {
|
|
// Urgency dot
|
|
Circle()
|
|
.fill(CMColors.urgencyColor(timer.urgency))
|
|
.frame(width: 8, height: 8)
|
|
|
|
VStack(alignment: .leading, spacing: CMSpacing.xxs) {
|
|
Text(timer.label)
|
|
.font(CMFonts.body(size: 14, weight: .medium))
|
|
.foregroundStyle(CMColors.text)
|
|
|
|
let endDate = timer.completedAt ?? timer.dismissedAt ?? timer.createdAt
|
|
Text(formatDateTime(endDate))
|
|
.font(CMFonts.body(size: 12))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// State badge
|
|
if timer.state == .completed {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(CMColors.success)
|
|
} else {
|
|
Image(systemName: "xmark.circle")
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
}
|
|
.padding(CMSpacing.md)
|
|
.background(CMColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
|
}
|
|
}
|