learning_ai_clock/ios/ChronoMind/Views/Routines/RoutineRunnerView.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

349 lines
11 KiB
Swift

// Routine Runner View
// Full-screen view for executing a routine step-by-step
import SwiftUI
struct RoutineRunnerView: View {
@State var routine: CMRoutine
let onComplete: (CMRoutine) -> Void
let onCancel: () -> Void
@State private var now = Date()
@State private var timer: Timer?
var body: some View {
ZStack {
CMColors.bg.ignoresSafeArea()
VStack(spacing: 0) {
// Header
header
// Progress bar
progressBar
// Current step
if let step = routine.currentStep {
currentStepCard(step)
} else if routine.status == .completed {
completedView
}
Spacer()
// Step list
stepList
// Controls
controls
}
}
.onAppear { startTicking() }
.onDisappear { stopTicking() }
}
// MARK: - Header
private var header: some View {
HStack {
Button {
routine.cancel()
onCancel()
} label: {
Image(systemName: "xmark")
.font(.title3)
.foregroundColor(CMColors.textSecondary)
}
Spacer()
VStack(spacing: 2) {
Text(routine.name)
.font(.headline)
.foregroundColor(CMColors.text)
Text("Step \(min(routine.currentStepIndex + 1, routine.steps.count)) of \(routine.steps.count)")
.font(.caption)
.foregroundColor(CMColors.textSecondary)
}
Spacer()
// Spacer for symmetry
Color.clear.frame(width: 28, height: 28)
}
.padding()
}
// MARK: - Progress Bar
private var progressBar: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(CMColors.surfaceHover)
.frame(height: 4)
RoundedRectangle(cornerRadius: 2)
.fill(CMColors.accent)
.frame(width: geo.size.width * routine.progress, height: 4)
.animation(.easeInOut(duration: 0.3), value: routine.progress)
}
}
.frame(height: 4)
.padding(.horizontal)
}
// MARK: - Current Step Card
@ViewBuilder
private func currentStepCard(_ step: CMRoutineStep) -> some View {
VStack(spacing: 16) {
Spacer().frame(height: 20)
// Step label
Text(step.label)
.font(.largeTitle.bold())
.foregroundColor(CMColors.text)
.multilineTextAlignment(.center)
// Remaining time
let remaining = routine.remainingStepSeconds(now: now)
Text(formatRemaining(remaining))
.font(.system(size: 60, weight: .thin, design: .monospaced))
.foregroundColor(remaining <= 30 ? CMColors.critical : CMColors.accent)
// Notes
if let notes = step.notes, !notes.isEmpty {
Text(notes)
.font(.subheadline)
.foregroundColor(CMColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// Next step preview
if let next = routine.nextStep {
HStack(spacing: 4) {
Text("Next:")
.font(.caption)
.foregroundColor(CMColors.textMuted)
Text(next.label)
.font(.caption.bold())
.foregroundColor(CMColors.textSecondary)
if step.transition != .immediate {
Text("(\(step.transition.label))")
.font(.caption2)
.foregroundColor(CMColors.textMuted)
}
}
.padding(.top, 8)
}
Spacer()
}
}
// MARK: - Completed View
private var completedView: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundColor(CMColors.gentle)
Text("Routine Complete!")
.font(.largeTitle.bold())
.foregroundColor(CMColors.text)
HStack(spacing: 24) {
VStack {
Text("\(routine.completedStepCount)")
.font(.title.bold())
.foregroundColor(CMColors.accent)
Text("Completed")
.font(.caption)
.foregroundColor(CMColors.textSecondary)
}
if routine.skippedStepCount > 0 {
VStack {
Text("\(routine.skippedStepCount)")
.font(.title.bold())
.foregroundColor(CMColors.important)
Text("Skipped")
.font(.caption)
.foregroundColor(CMColors.textSecondary)
}
}
}
Spacer()
Button {
onComplete(routine)
} label: {
Text("Done")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(CMColors.accent)
.cornerRadius(12)
}
.padding(.horizontal, 32)
.padding(.bottom, 32)
}
}
// MARK: - Step List
private var stepList: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(routine.steps.enumerated()), id: \.element.id) { idx, step in
stepPill(step, index: idx)
}
}
.padding(.horizontal)
}
.padding(.vertical, 8)
}
@ViewBuilder
private func stepPill(_ step: CMRoutineStep, index: Int) -> some View {
let isCurrent = index == routine.currentStepIndex && routine.status == .active
HStack(spacing: 4) {
statusIcon(step.status)
.font(.caption2)
Text(step.label)
.font(.caption2)
.lineLimit(1)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isCurrent ? CMColors.accent.opacity(0.2) : CMColors.surfaceHover)
.cornerRadius(16)
.foregroundColor(isCurrent ? CMColors.accent : statusColor(step.status))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isCurrent ? CMColors.accent : Color.clear, lineWidth: 1)
)
}
@ViewBuilder
private func statusIcon(_ status: StepStatus) -> some View {
switch status {
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(CMColors.gentle)
case .skipped:
Image(systemName: "forward.fill")
.foregroundColor(CMColors.important)
case .active:
Image(systemName: "play.circle.fill")
.foregroundColor(CMColors.accent)
case .pending:
Image(systemName: "circle")
.foregroundColor(CMColors.textMuted)
}
}
private func statusColor(_ status: StepStatus) -> Color {
switch status {
case .completed: return CMColors.gentle
case .skipped: return CMColors.important
case .active: return CMColors.accent
case .pending: return CMColors.textMuted
}
}
// MARK: - Controls
private var controls: some View {
HStack(spacing: 16) {
if routine.status == .active {
// Skip button
Button {
routine.skipCurrentStep()
checkCompletion()
} label: {
Image(systemName: "forward.fill")
.font(.title2)
.foregroundColor(CMColors.textSecondary)
.frame(width: 56, height: 56)
.background(CMColors.surfaceHover)
.clipShape(Circle())
}
// Complete step button
Button {
routine.completeCurrentStep()
checkCompletion()
} label: {
Image(systemName: "checkmark")
.font(.title.bold())
.foregroundColor(.white)
.frame(width: 72, height: 72)
.background(CMColors.accent)
.clipShape(Circle())
}
// Pause button
Button {
routine.pause()
} label: {
Image(systemName: "pause.fill")
.font(.title2)
.foregroundColor(CMColors.textSecondary)
.frame(width: 56, height: 56)
.background(CMColors.surfaceHover)
.clipShape(Circle())
}
} else if routine.status == .paused {
Button {
routine.resume()
} label: {
Image(systemName: "play.fill")
.font(.title.bold())
.foregroundColor(.white)
.frame(width: 72, height: 72)
.background(CMColors.accent)
.clipShape(Circle())
}
}
}
.padding(.bottom, 32)
}
// MARK: - Helpers
private func formatRemaining(_ seconds: TimeInterval) -> String {
let total = Int(max(0, seconds))
let m = total / 60
let s = total % 60
return String(format: "%d:%02d", m, s)
}
private func startTicking() {
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
now = Date()
if routine.shouldStepComplete(now: now) {
routine.completeCurrentStep()
checkCompletion()
}
}
}
private func stopTicking() {
timer?.invalidate()
timer = nil
}
private func checkCompletion() {
if routine.status == .completed {
stopTicking()
}
}
}