learning_ai_clock/ios/ChronoMind/Views/CreateTimer/CreateTimerView.swift
2026-02-27 21:15:30 -08:00

452 lines
17 KiB
Swift

// Create Timer View
// Modal for creating alarm, countdown, or pomodoro timers
import SwiftUI
struct CreateTimerView: View {
@EnvironmentObject var store: TimerStore
@Environment(\.dismiss) private var dismiss
@State private var timerType: CMTimerType = .countdown
@State private var label = ""
@State private var urgency: UrgencyLevel = .standard
@State private var cascadePreset: CascadePreset = .standard
@State private var category = ""
// Countdown fields
@State private var hours = 0
@State private var minutes = 25
@State private var seconds = 0
// Alarm fields
@State private var alarmDate = Date().addingTimeInterval(3600) // 1h from now
// Pomodoro fields
@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) {
// Timer type selector
timerTypePicker
// Label
CMTextField(
title: "Label",
placeholder: placeholderForType,
text: $label
)
// Type-specific fields
switch timerType {
case .countdown:
countdownPicker
case .alarm:
alarmPicker
case .pomodoro:
pomodoroPicker
case .event:
alarmPicker // same UI for now
}
// Urgency selector
urgencySelector
// Cascade preset
cascadeSelector
// Time blindness reference
if let ref = timeReference {
HStack {
Image(systemName: "lightbulb.fill")
.foregroundStyle(CMColors.standard)
.font(.caption)
Text(ref)
.font(CMFonts.body(size: 13))
.foregroundStyle(CMColors.textMuted)
.italic()
}
.padding(CMSpacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
.padding(CMSpacing.lg)
}
}
.navigationTitle("New Timer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.foregroundStyle(CMColors.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
createTimer()
dismiss()
}
.font(.body.weight(.semibold))
.foregroundStyle(CMColors.accent)
.disabled(!isValid)
}
}
.toolbarBackground(CMColors.surface, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
}
}
// MARK: - Timer Type Picker
private var timerTypePicker: some View {
HStack(spacing: 0) {
ForEach([CMTimerType.countdown, .alarm, .pomodoro], id: \.self) { type in
Button {
HapticEngine.selection()
withAnimation(.easeInOut(duration: 0.2)) {
timerType = type
}
} label: {
Text(type.label)
.font(CMFonts.body(size: 14, weight: timerType == type ? .semibold : .regular))
.foregroundStyle(timerType == type ? CMColors.text : CMColors.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.md)
.background(timerType == type ? CMColors.accent.opacity(0.15) : Color.clear)
}
}
}
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(CMColors.border, lineWidth: 1)
)
}
// MARK: - Countdown Picker
private var countdownPicker: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Duration")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
HStack(spacing: CMSpacing.md) {
durationWheel(title: "Hours", value: $hours, range: 0...23)
durationWheel(title: "Min", value: $minutes, range: 0...59)
durationWheel(title: "Sec", value: $seconds, range: 0...59)
}
.frame(height: 150)
}
}
private func durationWheel(title: String, value: Binding<Int>, range: ClosedRange<Int>) -> some View {
VStack(spacing: CMSpacing.xs) {
Text(title)
.font(CMFonts.body(size: 11, weight: .medium))
.foregroundStyle(CMColors.textMuted)
Picker(title, selection: value) {
ForEach(range, id: \.self) { n in
Text("\(n)")
.font(CMFonts.mono(size: 20))
.foregroundStyle(CMColors.text)
.tag(n)
}
}
.pickerStyle(.wheel)
}
.frame(maxWidth: .infinity)
}
// MARK: - Alarm Picker
private var alarmPicker: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Date & Time")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
DatePicker(
"Target Time",
selection: $alarmDate,
in: Date()...,
displayedComponents: [.date, .hourAndMinute]
)
.datePickerStyle(.graphical)
.tint(CMColors.accent)
.colorScheme(.dark)
}
}
// MARK: - Pomodoro Picker
private var pomodoroPicker: some View {
VStack(alignment: .leading, spacing: CMSpacing.md) {
Text("Pomodoro Settings")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
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 * (rounds - 1)) + longBreakMinutes
Text("Total: \(formatDurationCompact(TimeInterval(totalMinutes * 60)))")
.font(CMFonts.body(size: 13))
.foregroundStyle(CMColors.textMuted)
}
}
// MARK: - Urgency Selector
private var urgencySelector: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Urgency")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
HStack(spacing: CMSpacing.sm) {
ForEach(UrgencyLevel.allCases) { level in
Button {
HapticEngine.selection()
urgency = level
} label: {
VStack(spacing: CMSpacing.xxs) {
Circle()
.fill(CMColors.urgencyColor(level))
.frame(width: urgency == level ? 24 : 16, height: urgency == level ? 24 : 16)
.overlay(
Circle()
.stroke(Color.white, lineWidth: urgency == level ? 2 : 0)
)
Text(getUrgencyConfig(level).label)
.font(CMFonts.body(size: 10, weight: urgency == level ? .bold : .regular))
.foregroundStyle(urgency == level ? CMColors.text : CMColors.textMuted)
}
.frame(maxWidth: .infinity)
}
.animation(.easeInOut(duration: 0.15), value: urgency)
}
}
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}
// MARK: - Cascade Selector
private var cascadeSelector: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text("Pre-Warning Cascade")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: CMSpacing.sm) {
ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in
Button {
HapticEngine.selection()
cascadePreset = preset
} label: {
VStack(spacing: CMSpacing.xxs) {
Text(preset.label)
.font(CMFonts.body(size: 13, weight: cascadePreset == preset ? .semibold : .regular))
.foregroundStyle(cascadePreset == preset ? CMColors.text : CMColors.textMuted)
let intervals = preset.defaultIntervals
if intervals.isEmpty {
Text("")
.font(CMFonts.body(size: 10))
.foregroundStyle(CMColors.textMuted)
} else {
Text(intervals.map { formatMinutesBefore($0) }.joined(separator: ", "))
.font(CMFonts.body(size: 9))
.foregroundStyle(CMColors.textMuted)
.lineLimit(1)
}
}
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(cascadePreset == preset ? CMColors.accent.opacity(0.15) : CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(cascadePreset == preset ? CMColors.accent.opacity(0.4) : CMColors.border, lineWidth: 1)
)
}
}
}
}
}
}
// MARK: - Helpers
private var placeholderForType: String {
switch timerType {
case .countdown: return "e.g., Pasta timer"
case .alarm: return "e.g., Team standup"
case .pomodoro: return "e.g., Deep work session"
case .event: return "e.g., Vacation countdown"
}
}
private var timeReference: String? {
switch timerType {
case .countdown:
let totalSeconds = (hours * 3600) + (minutes * 60) + seconds
return getTimeReference(seconds: TimeInterval(totalSeconds))
case .pomodoro:
return getTimeReference(minutes: workMinutes)
default:
return nil
}
}
private var isValid: Bool {
switch timerType {
case .countdown:
return (hours + minutes + seconds) > 0
case .alarm, .event:
return alarmDate > Date()
case .pomodoro:
return workMinutes > 0
}
}
private func createTimer() {
let timerLabel = label.isEmpty ? placeholderForType.replacingOccurrences(of: "e.g., ", with: "") : label
let cascadeConfig = CascadeConfig(preset: cascadePreset, intervals: [])
switch timerType {
case .countdown:
let totalSeconds = TimeInterval((hours * 3600) + (minutes * 60) + seconds)
let _ = store.addCountdown(CreateCountdownParams(
label: timerLabel,
durationSeconds: totalSeconds,
urgency: urgency,
cascade: cascadeConfig,
category: category.isEmpty ? nil : category
))
case .alarm, .event:
let _ = store.addAlarm(CreateAlarmParams(
label: timerLabel,
targetTime: alarmDate,
urgency: urgency,
cascade: cascadeConfig,
category: category.isEmpty ? nil : category
))
case .pomodoro:
let config = PomodoroConfig(
workMinutes: workMinutes,
breakMinutes: breakMinutes,
longBreakMinutes: longBreakMinutes,
rounds: rounds
)
let _ = store.addPomodoro(CreatePomodoroParams(
label: timerLabel,
config: config,
urgency: urgency
))
}
HapticEngine.tap()
}
}
// MARK: - CM Text Field
struct CMTextField: View {
let title: String
let placeholder: String
@Binding var text: String
var body: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
Text(title)
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
TextField(placeholder, text: $text)
.font(CMFonts.body(size: 16))
.foregroundStyle(CMColors.text)
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(CMColors.border, lineWidth: 1)
)
}
}
}
// MARK: - CM Stepper
struct CMStepper: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
let suffix: String
var body: some View {
HStack {
Text(title)
.font(CMFonts.body(size: 14))
.foregroundStyle(CMColors.textSecondary)
Spacer()
HStack(spacing: CMSpacing.md) {
Button {
if value > range.lowerBound {
value -= 1
HapticEngine.selection()
}
} label: {
Image(systemName: "minus.circle.fill")
.font(.title3)
.foregroundStyle(value > range.lowerBound ? CMColors.textSecondary : CMColors.textMuted)
}
.disabled(value <= range.lowerBound)
Text("\(value)\(suffix.isEmpty ? "" : " \(suffix)")")
.font(CMFonts.mono(size: 16, weight: .semibold))
.foregroundStyle(CMColors.text)
.frame(minWidth: 60)
Button {
if value < range.upperBound {
value += 1
HapticEngine.selection()
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title3)
.foregroundStyle(value < range.upperBound ? CMColors.accent : CMColors.textMuted)
}
.disabled(value >= range.upperBound)
}
}
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
}