- 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
211 lines
7.2 KiB
Swift
211 lines
7.2 KiB
Swift
// ── Routine List View ─────────────────────────────────────────
|
|
// Displays built-in templates and user routines with start/edit actions
|
|
|
|
import SwiftUI
|
|
|
|
struct RoutineListView: View {
|
|
@State private var routines: [CMRoutine] = RoutineTemplates.all
|
|
@State private var activeRoutine: CMRoutine?
|
|
@State private var showEditor = false
|
|
@State private var editingRoutine: CMRoutine?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
// Active routine banner
|
|
if let active = activeRoutine {
|
|
activeRoutineBanner(active)
|
|
}
|
|
|
|
// Templates section
|
|
sectionHeader("Templates")
|
|
ForEach(routines.filter { $0.isTemplate }) { routine in
|
|
routineCard(routine)
|
|
}
|
|
|
|
// User routines section
|
|
let userRoutines = routines.filter { !$0.isTemplate }
|
|
if !userRoutines.isEmpty {
|
|
sectionHeader("My Routines")
|
|
ForEach(userRoutines) { routine in
|
|
routineCard(routine)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.background(CMColors.bg.ignoresSafeArea())
|
|
.navigationTitle("Routines")
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
editingRoutine = nil
|
|
showEditor = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.foregroundColor(CMColors.accent)
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showEditor) {
|
|
RoutineEditorView(
|
|
routine: editingRoutine,
|
|
onSave: { routine in
|
|
if let idx = routines.firstIndex(where: { $0.id == routine.id }) {
|
|
routines[idx] = routine
|
|
} else {
|
|
routines.append(routine)
|
|
}
|
|
showEditor = false
|
|
},
|
|
onCancel: { showEditor = false }
|
|
)
|
|
}
|
|
.fullScreenCover(item: $activeRoutine) { routine in
|
|
RoutineRunnerView(
|
|
routine: routine,
|
|
onComplete: { completed in
|
|
activeRoutine = nil
|
|
if let idx = routines.firstIndex(where: { $0.id == completed.id }) {
|
|
routines[idx] = completed
|
|
}
|
|
},
|
|
onCancel: {
|
|
activeRoutine = nil
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Subviews
|
|
|
|
@ViewBuilder
|
|
private func sectionHeader(_ title: String) -> some View {
|
|
HStack {
|
|
Text(title)
|
|
.font(.headline)
|
|
.foregroundColor(CMColors.textSecondary)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func routineCard(_ routine: CMRoutine) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(routine.name)
|
|
.font(.title3.bold())
|
|
.foregroundColor(CMColors.text)
|
|
|
|
if let desc = routine.routineDescription {
|
|
Text(desc)
|
|
.font(.subheadline)
|
|
.foregroundColor(CMColors.textSecondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("\(routine.totalDurationMinutes) min")
|
|
.font(.caption.bold())
|
|
.foregroundColor(CMColors.accent)
|
|
|
|
Text("\(routine.steps.count) steps")
|
|
.font(.caption2)
|
|
.foregroundColor(CMColors.textMuted)
|
|
}
|
|
}
|
|
|
|
// Step preview
|
|
HStack(spacing: 4) {
|
|
ForEach(routine.steps.prefix(5)) { step in
|
|
Text(step.label)
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(CMColors.surfaceHover)
|
|
.cornerRadius(4)
|
|
.foregroundColor(CMColors.textSecondary)
|
|
}
|
|
if routine.steps.count > 5 {
|
|
Text("+\(routine.steps.count - 5)")
|
|
.font(.caption2)
|
|
.foregroundColor(CMColors.textMuted)
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
var instance = routine.isTemplate ? routine.instantiate() : routine
|
|
instance.start()
|
|
activeRoutine = instance
|
|
} label: {
|
|
Label("Start", systemImage: "play.fill")
|
|
.font(.subheadline.bold())
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(CMColors.accent)
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
if !routine.isTemplate {
|
|
Button {
|
|
editingRoutine = routine
|
|
showEditor = true
|
|
} label: {
|
|
Label("Edit", systemImage: "pencil")
|
|
.font(.subheadline)
|
|
.foregroundColor(CMColors.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
.padding()
|
|
.background(CMColors.surface)
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func activeRoutineBanner(_ routine: CMRoutine) -> some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Active Routine")
|
|
.font(.caption.bold())
|
|
.foregroundColor(CMColors.accent)
|
|
Text(routine.name)
|
|
.font(.headline)
|
|
.foregroundColor(CMColors.text)
|
|
if let step = routine.currentStep {
|
|
Text("Step: \(step.label)")
|
|
.font(.subheadline)
|
|
.foregroundColor(CMColors.textSecondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button("Resume") {
|
|
activeRoutine = routine
|
|
}
|
|
.font(.subheadline.bold())
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(CMColors.accent)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(8)
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(CMColors.surface)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(CMColors.accent.opacity(0.3), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
}
|