- 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
244 lines
9.1 KiB
Swift
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
|
|
}
|
|
}
|