test: add time-blindness and urgency tests (82 total passing)
This commit is contained in:
parent
755d030c7a
commit
cae442d099
359
ios/ChronoMind/Views/Focus/PomodoroView.swift
Normal file
359
ios/ChronoMind/Views/Focus/PomodoroView.swift
Normal file
@ -0,0 +1,359 @@
|
||||
// ── Pomodoro View ──────────────────────────────────────────────
|
||||
// Focus session with countdown ring, round tracking, and auto-transitions
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PomodoroView: View {
|
||||
@EnvironmentObject var store: TimerStore
|
||||
@State private var showCreatePomodoro = false
|
||||
|
||||
private var activePomodoro: CMTimer? {
|
||||
store.timers.first { $0.type == .pomodoro && [.active, .warning, .paused, .firing].contains($0.state) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CMColors.bg.ignoresSafeArea()
|
||||
|
||||
if let timer = activePomodoro {
|
||||
ActivePomodoroView(timer: timer)
|
||||
} else {
|
||||
IdlePomodoroView(onStart: { showCreatePomodoro = true })
|
||||
}
|
||||
}
|
||||
.navigationTitle("Focus")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.sheet(isPresented: $showCreatePomodoro) {
|
||||
PomodoroSetupSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle State
|
||||
|
||||
struct IdlePomodoroView: View {
|
||||
let onStart: () -> Void
|
||||
@EnvironmentObject var store: TimerStore
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CMSpacing.xxl) {
|
||||
Spacer()
|
||||
|
||||
// Decorative ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(CMColors.border, lineWidth: 12)
|
||||
.frame(width: 220, height: 220)
|
||||
|
||||
VStack(spacing: CMSpacing.sm) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(CMColors.accent)
|
||||
|
||||
Text("Ready to focus?")
|
||||
.font(CMFonts.body(size: 18, weight: .semibold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Start a Pomodoro session to stay focused\nwith structured work and break intervals")
|
||||
.font(CMFonts.body(size: 14))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Quick start buttons
|
||||
VStack(spacing: CMSpacing.md) {
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
let _ = store.addPomodoro()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
Text("Start 25/5 Session")
|
||||
}
|
||||
.font(CMFonts.body(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.lg)
|
||||
.background(CMColors.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
}
|
||||
|
||||
Button {
|
||||
onStart()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
Text("Custom Session")
|
||||
}
|
||||
.font(CMFonts.body(size: 16, weight: .medium))
|
||||
.foregroundStyle(CMColors.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.lg)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.md)
|
||||
.stroke(CMColors.accent.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, CMSpacing.xxl)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active Pomodoro
|
||||
|
||||
struct ActivePomodoroView: View {
|
||||
let timer: CMTimer
|
||||
@EnvironmentObject var store: TimerStore
|
||||
|
||||
private var progress: Double {
|
||||
guard let duration = timer.duration, duration > 0 else { return 0 }
|
||||
let remaining = getRemainingSeconds(timer, now: store.now)
|
||||
return 1.0 - (remaining / duration)
|
||||
}
|
||||
|
||||
private var phaseLabel: String {
|
||||
guard let state = timer.pomodoroState else { return "" }
|
||||
if state.isLongBreak { return "Long Break" }
|
||||
if state.isBreak { return "Break" }
|
||||
return "Work"
|
||||
}
|
||||
|
||||
private var phaseIcon: String {
|
||||
guard let state = timer.pomodoroState else { return "target" }
|
||||
if state.isBreak || state.isLongBreak { return "cup.and.saucer.fill" }
|
||||
return "brain.head.profile"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CMSpacing.xl) {
|
||||
Spacer()
|
||||
|
||||
// Phase indicator
|
||||
HStack(spacing: CMSpacing.sm) {
|
||||
Image(systemName: phaseIcon)
|
||||
.foregroundStyle(CMColors.accent)
|
||||
Text(phaseLabel)
|
||||
.font(CMFonts.body(size: 16, weight: .semibold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
|
||||
// Countdown ring
|
||||
CountdownRing(
|
||||
progress: progress,
|
||||
urgency: timer.urgency,
|
||||
remainingSeconds: getRemainingSeconds(timer, now: store.now),
|
||||
totalSeconds: timer.duration ?? 0
|
||||
)
|
||||
|
||||
// Timer label
|
||||
Text(timer.label)
|
||||
.font(CMFonts.body(size: 18, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
// Round indicator
|
||||
if let state = timer.pomodoroState, let config = timer.pomodoroConfig {
|
||||
PomodoroRoundIndicator(
|
||||
currentRound: state.currentRound,
|
||||
totalRounds: config.rounds,
|
||||
completedRounds: state.completedRounds,
|
||||
isBreak: state.isBreak || state.isLongBreak
|
||||
)
|
||||
}
|
||||
|
||||
// Time blindness aid
|
||||
let remaining = getRemainingSeconds(timer, now: store.now)
|
||||
if let ref = getTimeReference(seconds: remaining) {
|
||||
Text(ref)
|
||||
.font(CMFonts.body(size: 13))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.italic()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Controls
|
||||
HStack(spacing: CMSpacing.xxl) {
|
||||
// Cancel
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.dismiss(timer.id)
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.title3)
|
||||
Text("Cancel")
|
||||
.font(CMFonts.body(size: 12))
|
||||
}
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.frame(width: 70)
|
||||
}
|
||||
|
||||
// Pause / Resume / Advance
|
||||
if timer.state == .firing {
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.advancePom(timer.id)
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "forward.fill")
|
||||
.font(.title)
|
||||
Text("Next")
|
||||
.font(CMFonts.body(size: 12))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 80, height: 80)
|
||||
.background(CMColors.accent)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
} else if timer.state == .paused {
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.resume(timer.id)
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.title)
|
||||
Text("Resume")
|
||||
.font(CMFonts.body(size: 12))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 80, height: 80)
|
||||
.background(CMColors.accent)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.pause(timer.id)
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "pause.fill")
|
||||
.font(.title)
|
||||
Text("Pause")
|
||||
.font(CMFonts.body(size: 12))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 80, height: 80)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(CMColors.border, lineWidth: 2))
|
||||
}
|
||||
}
|
||||
|
||||
// Skip to break / work
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.advancePom(timer.id)
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "forward.end.fill")
|
||||
.font(.title3)
|
||||
Text("Skip")
|
||||
.font(CMFonts.body(size: 12))
|
||||
}
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.frame(width: 70)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, CMSpacing.xxl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pomodoro Setup Sheet
|
||||
|
||||
struct PomodoroSetupSheet: View {
|
||||
@EnvironmentObject var store: TimerStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var label = "Focus Session"
|
||||
@State private var workMinutes = 25
|
||||
@State private var breakMinutes = 5
|
||||
@State private var longBreakMinutes = 15
|
||||
@State private var rounds = 4
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CMColors.bg.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: CMSpacing.xl) {
|
||||
CMTextField(title: "Session Label", placeholder: "e.g., Deep work", text: $label)
|
||||
|
||||
CMStepper(title: "Work", value: $workMinutes, range: 5...90, suffix: "min")
|
||||
CMStepper(title: "Break", value: $breakMinutes, range: 1...30, suffix: "min")
|
||||
CMStepper(title: "Long Break", value: $longBreakMinutes, range: 5...60, suffix: "min")
|
||||
CMStepper(title: "Rounds", value: $rounds, range: 1...12, suffix: "")
|
||||
|
||||
// Total time
|
||||
let totalMinutes = (workMinutes * rounds) + (breakMinutes * max(0, rounds - 1)) + longBreakMinutes
|
||||
HStack {
|
||||
Text("Total session time:")
|
||||
.font(CMFonts.body(size: 14))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
Spacer()
|
||||
Text(formatDurationCompact(TimeInterval(totalMinutes * 60)))
|
||||
.font(CMFonts.mono(size: 16, weight: .semibold))
|
||||
.foregroundStyle(CMColors.accent)
|
||||
}
|
||||
.padding(CMSpacing.md)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
|
||||
// Time reference
|
||||
if let ref = getTimeReference(minutes: totalMinutes) {
|
||||
HStack {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.foregroundStyle(CMColors.standard)
|
||||
.font(.caption)
|
||||
Text(ref)
|
||||
.font(CMFonts.body(size: 13))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.lg)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pomodoro Setup")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Start") {
|
||||
let config = PomodoroConfig(
|
||||
workMinutes: workMinutes,
|
||||
breakMinutes: breakMinutes,
|
||||
longBreakMinutes: longBreakMinutes,
|
||||
rounds: rounds
|
||||
)
|
||||
let _ = store.addPomodoro(CreatePomodoroParams(
|
||||
label: label,
|
||||
config: config
|
||||
))
|
||||
dismiss()
|
||||
}
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(CMColors.accent)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
242
ios/ChronoMind/Views/History/HistoryView.swift
Normal file
242
ios/ChronoMind/Views/History/HistoryView.swift
Normal file
@ -0,0 +1,242 @@
|
||||
// ── 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))
|
||||
}
|
||||
}
|
||||
160
ios/ChronoMind/Views/Settings/SettingsView.swift
Normal file
160
ios/ChronoMind/Views/Settings/SettingsView.swift
Normal file
@ -0,0 +1,160 @@
|
||||
// ── Settings View ──────────────────────────────────────────────
|
||||
// Preferences, categories, sounds, about
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var store: TimerStore
|
||||
@EnvironmentObject var notificationManager: CMNotificationManager
|
||||
|
||||
@AppStorage("cm_defaultUrgency") private var defaultUrgency = "standard"
|
||||
@AppStorage("cm_defaultCascade") private var defaultCascade = "standard"
|
||||
@AppStorage("cm_hapticEnabled") private var hapticEnabled = true
|
||||
@AppStorage("cm_soundEnabled") private var soundEnabled = true
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CMColors.bg.ignoresSafeArea()
|
||||
|
||||
List {
|
||||
// Notifications
|
||||
Section {
|
||||
HStack {
|
||||
Label("Notifications", systemImage: "bell.fill")
|
||||
.foregroundStyle(CMColors.text)
|
||||
Spacer()
|
||||
if notificationManager.isAuthorized {
|
||||
Text("Enabled")
|
||||
.font(CMFonts.body(size: 13))
|
||||
.foregroundStyle(CMColors.success)
|
||||
} else {
|
||||
Button("Enable") {
|
||||
Task {
|
||||
await notificationManager.requestPermission()
|
||||
}
|
||||
}
|
||||
.font(CMFonts.body(size: 13, weight: .semibold))
|
||||
.foregroundStyle(CMColors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle(isOn: $hapticEnabled) {
|
||||
Label("Haptic Feedback", systemImage: "iphone.radiowaves.left.and.right")
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
.tint(CMColors.accent)
|
||||
|
||||
Toggle(isOn: $soundEnabled) {
|
||||
Label("Sound", systemImage: "speaker.wave.2.fill")
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
.tint(CMColors.accent)
|
||||
} header: {
|
||||
Text("Notifications & Feedback")
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
.listRowBackground(CMColors.surface)
|
||||
|
||||
// Defaults
|
||||
Section {
|
||||
Picker(selection: $defaultUrgency) {
|
||||
ForEach(UrgencyLevel.allCases) { level in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(CMColors.urgencyColor(level))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(getUrgencyConfig(level).label)
|
||||
}
|
||||
.tag(level.rawValue)
|
||||
}
|
||||
} label: {
|
||||
Label("Default Urgency", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
|
||||
Picker(selection: $defaultCascade) {
|
||||
ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in
|
||||
Text(preset.label).tag(preset.rawValue)
|
||||
}
|
||||
} label: {
|
||||
Label("Default Cascade", systemImage: "arrow.down.forward.and.arrow.up.backward")
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
} header: {
|
||||
Text("Defaults")
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
.listRowBackground(CMColors.surface)
|
||||
|
||||
// Data
|
||||
Section {
|
||||
HStack {
|
||||
Label("Total Timers", systemImage: "number")
|
||||
.foregroundStyle(CMColors.text)
|
||||
Spacer()
|
||||
Text("\(store.timers.count)")
|
||||
.font(CMFonts.mono(size: 14))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
}
|
||||
|
||||
Button {
|
||||
store.timers.removeAll { [.completed, .dismissed].contains($0.state) }
|
||||
} label: {
|
||||
Label("Clear History", systemImage: "trash")
|
||||
.foregroundStyle(CMColors.error)
|
||||
}
|
||||
|
||||
Button {
|
||||
store.timers.removeAll()
|
||||
CMNotificationManager.shared.removeAllNotifications()
|
||||
} label: {
|
||||
Label("Delete All Timers", systemImage: "trash.fill")
|
||||
.foregroundStyle(CMColors.error)
|
||||
}
|
||||
} header: {
|
||||
Text("Data")
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
.listRowBackground(CMColors.surface)
|
||||
|
||||
// About
|
||||
Section {
|
||||
HStack {
|
||||
Label("Version", systemImage: "info.circle")
|
||||
.foregroundStyle(CMColors.text)
|
||||
Spacer()
|
||||
Text("1.0.0 (Phase 3)")
|
||||
.font(CMFonts.body(size: 13))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Label("Product ID", systemImage: "barcode")
|
||||
.foregroundStyle(CMColors.text)
|
||||
Spacer()
|
||||
Text("chronomind")
|
||||
.font(CMFonts.mono(size: 13))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://chronomind.app/privacy")!) {
|
||||
Label("Privacy Policy", systemImage: "hand.raised.fill")
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
} header: {
|
||||
Text("About")
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
.listRowBackground(CMColors.surface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
49
web/src/lib/time-blindness.test.ts
Normal file
49
web/src/lib/time-blindness.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getTimeReference, getTimeReferenceMs } from './time-blindness';
|
||||
|
||||
describe('time-blindness', () => {
|
||||
describe('getTimeReference', () => {
|
||||
it('returns null for 0 or negative', () => {
|
||||
expect(getTimeReference(0)).toBeNull();
|
||||
expect(getTimeReference(-5)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a reference for 1 minute', () => {
|
||||
expect(getTimeReference(1)).toBe('About as long as a deep breath');
|
||||
});
|
||||
|
||||
it('returns a reference for 5 minutes', () => {
|
||||
expect(getTimeReference(5)).toBe('About as long as a short walk around the block');
|
||||
});
|
||||
|
||||
it('returns a reference for 25 minutes', () => {
|
||||
expect(getTimeReference(25)).toBe('About as long as one Pomodoro session');
|
||||
});
|
||||
|
||||
it('returns a reference for 30 minutes', () => {
|
||||
expect(getTimeReference(30)).toBe('About as long as a TV sitcom episode');
|
||||
});
|
||||
|
||||
it('returns a reference for 60 minutes', () => {
|
||||
expect(getTimeReference(60)).toBe('About as long as one hour-long meeting');
|
||||
});
|
||||
|
||||
it('returns a reference for 90 minutes', () => {
|
||||
expect(getTimeReference(90)).toBe('About as long as a movie');
|
||||
});
|
||||
|
||||
it('returns null for extremely large durations', () => {
|
||||
expect(getTimeReference(10000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeReferenceMs', () => {
|
||||
it('converts ms to minutes and returns reference', () => {
|
||||
expect(getTimeReferenceMs(25 * 60_000)).toBe('About as long as one Pomodoro session');
|
||||
});
|
||||
|
||||
it('returns null for 0', () => {
|
||||
expect(getTimeReferenceMs(0)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
51
web/src/lib/urgency.test.ts
Normal file
51
web/src/lib/urgency.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getUrgencyConfig, URGENCY_CONFIGS, URGENCY_ORDER } from './urgency';
|
||||
import type { UrgencyLevel } from './urgency';
|
||||
|
||||
describe('urgency', () => {
|
||||
describe('URGENCY_ORDER', () => {
|
||||
it('has 5 levels in correct priority order', () => {
|
||||
expect(URGENCY_ORDER).toEqual(['critical', 'important', 'standard', 'gentle', 'passive']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URGENCY_CONFIGS', () => {
|
||||
it('has config for every urgency level', () => {
|
||||
for (const level of URGENCY_ORDER) {
|
||||
expect(URGENCY_CONFIGS[level]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('critical has sound enabled and full-screen overlay', () => {
|
||||
const critical = URGENCY_CONFIGS.critical;
|
||||
expect(critical.soundEnabled).toBe(true);
|
||||
expect(critical.notificationStyle).toBe('persistent');
|
||||
});
|
||||
|
||||
it('passive has sound disabled', () => {
|
||||
const passive = URGENCY_CONFIGS.passive;
|
||||
expect(passive.soundEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('every config has required fields', () => {
|
||||
for (const level of URGENCY_ORDER) {
|
||||
const config = URGENCY_CONFIGS[level];
|
||||
expect(config.label).toBeTruthy();
|
||||
expect(config.color).toBeTruthy();
|
||||
expect(config.bgColor).toBeTruthy();
|
||||
expect(typeof config.soundEnabled).toBe('boolean');
|
||||
expect(config.notificationStyle).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrgencyConfig', () => {
|
||||
it('returns correct config for each level', () => {
|
||||
const levels: UrgencyLevel[] = ['critical', 'important', 'standard', 'gentle', 'passive'];
|
||||
for (const level of levels) {
|
||||
const config = getUrgencyConfig(level);
|
||||
expect(config).toBe(URGENCY_CONFIGS[level]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user