- 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
349 lines
11 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|