452 lines
17 KiB
Swift
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))
|
|
}
|
|
}
|