learning_ai_clock/ios/ChronoMind/Views/Routines/RoutineEditorView.swift
saravanakumardb1 11e50295ea feat: fix web build, add repo infra, port iOS engine modules, add routine screens
- fix(web): cast window through unknown in platform-sync.ts (TS2352)
- docs: add AGENTS.md, README.md, CLAUDE.md, .windsurfrules, .cursorrules, env.example
- feat(ios): port Recurrence.swift from web/src/lib/recurrence.ts
- feat(ios): port NLParser.swift from web/src/lib/nl-parser.ts
- feat(ios): port ContextMessages.swift from web/src/lib/context-messages.ts
- feat(ios): add CMRoutine model + Routines.swift engine with state machine + templates
- feat(ios): add RoutineListView, RoutineRunnerView, RoutineEditorView
- feat(android): add RoutineScreen.kt with list, runner, templates, step controls

Web: 373 tests passing, build succeeds with --webpack flag
2026-02-28 01:50:35 -08:00

244 lines
9.1 KiB
Swift

// Routine Editor View
// Create or edit a routine with steps, durations, and transitions
import SwiftUI
struct RoutineEditorView: View {
let routine: CMRoutine?
let onSave: (CMRoutine) -> Void
let onCancel: () -> Void
@State private var name: String = ""
@State private var description: String = ""
@State private var steps: [CMRoutineStep] = []
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Name & description
VStack(alignment: .leading, spacing: 8) {
Text("Name")
.font(.subheadline.bold())
.foregroundColor(CMColors.textSecondary)
TextField("Morning Routine", text: $name)
.textFieldStyle(.plain)
.padding(10)
.background(CMColors.surfaceHover)
.cornerRadius(8)
.foregroundColor(CMColors.text)
Text("Description")
.font(.subheadline.bold())
.foregroundColor(CMColors.textSecondary)
TextField("Start your day with intention", text: $description)
.textFieldStyle(.plain)
.padding(10)
.background(CMColors.surfaceHover)
.cornerRadius(8)
.foregroundColor(CMColors.text)
}
// Steps
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Steps")
.font(.subheadline.bold())
.foregroundColor(CMColors.textSecondary)
Spacer()
Text("\(totalDuration) min total")
.font(.caption)
.foregroundColor(CMColors.accent)
}
ForEach(Array(steps.enumerated()), id: \.element.id) { idx, step in
stepRow(step, index: idx)
}
Button {
steps.append(CMRoutineStep(
label: "",
durationMinutes: 10
))
} label: {
Label("Add Step", systemImage: "plus.circle.fill")
.font(.subheadline)
.foregroundColor(CMColors.accent)
}
.padding(.top, 4)
}
}
.padding()
}
.background(CMColors.bg.ignoresSafeArea())
.navigationTitle(routine == nil ? "New Routine" : "Edit Routine")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onCancel() }
.foregroundColor(CMColors.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.foregroundColor(CMColors.accent)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || steps.isEmpty)
}
}
.onAppear {
if let r = routine {
name = r.name
description = r.routineDescription ?? ""
steps = r.steps
}
}
}
}
// MARK: - Step Row
@ViewBuilder
private func stepRow(_ step: CMRoutineStep, index: Int) -> some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
// Drag handle / index
Text("\(index + 1)")
.font(.caption.bold())
.foregroundColor(CMColors.textMuted)
.frame(width: 20)
// Label
TextField("Step name", text: Binding(
get: { steps[safe: index]?.label ?? "" },
set: { if steps.indices.contains(index) { steps[index].label = $0 } }
))
.textFieldStyle(.plain)
.padding(8)
.background(CMColors.surfaceHover)
.cornerRadius(6)
.foregroundColor(CMColors.text)
.font(.subheadline)
// Duration stepper
HStack(spacing: 4) {
Button {
if steps.indices.contains(index) && steps[index].durationMinutes > 1 {
steps[index].durationMinutes -= 1
}
} label: {
Image(systemName: "minus")
.font(.caption2)
.foregroundColor(CMColors.textSecondary)
}
Text("\(steps[safe: index]?.durationMinutes ?? 0)m")
.font(.caption.monospacedDigit())
.foregroundColor(CMColors.accent)
.frame(width: 30)
Button {
if steps.indices.contains(index) {
steps[index].durationMinutes += 1
}
} label: {
Image(systemName: "plus")
.font(.caption2)
.foregroundColor(CMColors.textSecondary)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(CMColors.surface)
.cornerRadius(6)
// Delete
Button {
steps.remove(at: index)
} label: {
Image(systemName: "trash")
.font(.caption)
.foregroundColor(CMColors.critical.opacity(0.7))
}
}
// Transition picker (not on last step)
if index < steps.count - 1 {
HStack(spacing: 8) {
Spacer().frame(width: 20)
Image(systemName: "arrow.down")
.font(.caption2)
.foregroundColor(CMColors.textMuted)
Picker("Transition", selection: Binding(
get: { steps[safe: index]?.transition ?? .immediate },
set: { if steps.indices.contains(index) { steps[index].transition = $0 } }
)) {
ForEach(TransitionType.allCases) { t in
Text(t.label).tag(t)
}
}
.pickerStyle(.menu)
.tint(CMColors.textSecondary)
.font(.caption)
Spacer()
}
}
// Notes
TextField("Notes (optional)", text: Binding(
get: { steps[safe: index]?.notes ?? "" },
set: { if steps.indices.contains(index) { steps[index].notes = $0.isEmpty ? nil : $0 } }
))
.textFieldStyle(.plain)
.padding(6)
.background(CMColors.surfaceHover.opacity(0.5))
.cornerRadius(4)
.foregroundColor(CMColors.textMuted)
.font(.caption)
.padding(.leading, 28)
}
.padding(10)
.background(CMColors.surface)
.cornerRadius(8)
}
// MARK: - Computed
private var totalDuration: Int {
CMRoutine.calculateTotalDuration(steps: steps)
}
// MARK: - Actions
private func save() {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty, !steps.isEmpty else { return }
let validSteps = steps.filter { !$0.label.trimmingCharacters(in: .whitespaces).isEmpty }
guard !validSteps.isEmpty else { return }
if var existing = routine {
existing.name = trimmedName
existing.routineDescription = description.isEmpty ? nil : description
existing.steps = validSteps
existing.totalDurationMinutes = CMRoutine.calculateTotalDuration(steps: validSteps)
onSave(existing)
} else {
let newRoutine = CMRoutine(
name: trimmedName,
routineDescription: description.isEmpty ? nil : description,
steps: validSteps
)
onSave(newRoutine)
}
}
}
// MARK: - Safe Array Subscript
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}