// // RoutinesLibraryView.swift // Workouts // // Created by rzen on 7/25/25 at 6:24 PM. // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData /// The Routines segment of the Library tab: a plain list of routines, one row each, /// ordered and reorderable via `EditButton` + drag. A leading swipe duplicates a /// routine; a trailing swipe edits or deletes. Starter routines are seeded /// automatically on the true first run (`SyncEngine.autoSeedIfEmpty`), so an empty /// library just invites creating one. Pushed from `LibraryView`, so it relies on that /// view's `NavigationStack` and contributes only its own toolbar items (+ and Edit). struct RoutinesLibraryView: View { @Environment(SyncEngine.self) private var sync @Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)]) private var routines: [Routine] @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] @State private var showingAddSheet = false @State private var routineToEdit: Routine? @State private var routineToDelete: Routine? /// Most recent *completed* workout date per routine — "trained", not merely /// started, mirroring `ProgressTabView`'s `completed: workout.status == .completed`. /// `workouts` sorts start-descending, so the first completed match per live routine /// id is its latest. Each workout's `routineID` is resolved through /// `sync.currentRoutineID(for:)` so a workout logged against a since-edited seed /// still credits the live clone. private var lastTrainedByRoutineID: [String: Date] { var map: [String: Date] = [:] for workout in workouts where workout.status == .completed { guard let routineID = workout.routineID else { continue } let liveID = sync.currentRoutineID(for: routineID) if map[liveID] == nil { map[liveID] = workout.start } } return map } var body: some View { let lastTrained = lastTrainedByRoutineID List { ForEach(routines) { routine in NavigationLink { RoutineDetailView(routine: routine) } label: { RoutineRow(routine: routine, lastTrained: lastTrained[routine.id]) } .swipeActions(edge: .leading) { Button { Task { await sync.duplicate(routine: routine) } } label: { Label("Duplicate", systemImage: "plus.square.on.square") } .tint(.teal) } .swipeActions { Button(role: .destructive) { routineToDelete = routine } label: { Label("Delete", systemImage: "trash") } Button { routineToEdit = routine } label: { Label("Edit", systemImage: "pencil") } .tint(.indigo) } } .onMove(perform: moveRoutines) } .listStyle(.insetGrouped) .overlay { if routines.isEmpty { ContentUnavailableView( "No Routines Yet", systemImage: "dumbbell.fill", description: Text("Create a routine to organize your workout routine.") ) } } .navigationTitle("Routines") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { showingAddSheet = true } label: { Image(systemName: "plus") } .accessibilityLabel("Add Routine") } ToolbarItem(placement: .navigationBarTrailing) { EditButton() } } .sheet(isPresented: $showingAddSheet) { RoutineAddEditView(routine: nil) } .sheet(item: $routineToEdit) { routine in RoutineAddEditView(routine: routine) } .confirmationDialog( "Delete Routine?", isPresented: Binding( get: { routineToDelete != nil }, set: { if !$0 { routineToDelete = nil } } ), titleVisibility: .visible, presenting: routineToDelete ) { routine in Button("Delete", role: .destructive) { Task { await sync.delete(routine: routine) } routineToDelete = nil } Button("Cancel", role: .cancel) { routineToDelete = nil } } message: { routine in Text("This will permanently delete \"\(routine.name)\" and all its exercises. Past workouts are kept.") } } /// Reorder and renumber via `RoutineOrdering`, writing only the routines whose /// order actually changed. Order-only writes deliberately leave `updatedAt` /// alone — it's bookkeeping, not content, and touching it needlessly risks forking /// a starter (its edit sheets already guard "pristine" saves against exactly that). private func moveRoutines(from source: IndexSet, to destination: Int) { let ids = routines.map(\.id) let currentOrders = Dictionary(uniqueKeysWithValues: routines.map { ($0.id, $0.order) }) let changes = RoutineOrdering.changedOrders( ids: ids, currentOrders: currentOrders, from: source, to: destination) guard !changes.isEmpty else { return } Task { for change in changes { guard let routine = routines.first(where: { $0.id == change.id }), routine.isLive else { continue } var doc = RoutineDocument(from: routine) doc.order = change.order await sync.save(routine: doc) } } } } /// A single routine row: a small rounded-square badge (the routine's color at low /// opacity, its symbol tinted full-strength) beside the routine name — with a small /// "Starter" capsule for a bundled seed — its exercise count, and (once it's been /// trained at least once) how long ago. private struct RoutineRow: View { var routine: Routine var lastTrained: Date? var body: some View { HStack(spacing: 12) { Image(systemName: routine.systemImage) .font(.title3) .foregroundStyle(routineColor) .frame(width: 36, height: 36) .background(routineColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(routine.name) .foregroundStyle(.primary) if SeedLibrary.isSeed(id: routine.id) { Text("Starter") .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.secondary.opacity(0.15), in: Capsule()) } } Text(caption) .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 2) } private var routineColor: Color { Color.color(from: routine.color) } private var caption: String { let base = "\(routine.exercisesArray.count) exercises" guard let lastTrained else { return base } return "\(base) · Last trained \(lastTrained.daysAgoLabel())" } }