Queue document writes durably and surface sync trouble in the UI
iCloud Drive writes now flow through a persistent WriteBacklog sidecar (drained with backoff, flushed on backgrounding, wiped with the cache on account change), so a save can never be lost to a transient coordinator error. A status banner on the workout list surfaces stuck syncing. Also: the split picker gains a Recent section with day labels, split rows fold SplitItem into SplitListView, and list rows dim the multiply sign in sets-by-reps. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// SyncStatusBanner.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Surfaces the write queue's banner tiers at the bottom of the main screen.
|
||||
/// Tier 0 (fresh writes, silent retries) shows nothing; tier 1 is a calm
|
||||
/// "changes pending" capsule that clears itself on success; tier 2 is a
|
||||
/// prominent fault that opens Diagnostics.
|
||||
struct SyncStatusBanner: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@State private var showingDiagnostics = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch sync.writeQueueState {
|
||||
case .idle, .pending:
|
||||
EmptyView()
|
||||
case .retrying:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Changes pending — retrying…")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.padding(.bottom, 6)
|
||||
case .fault(let message):
|
||||
Button {
|
||||
showingDiagnostics = true
|
||||
} label: {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: "exclamationmark.icloud.fill")
|
||||
.foregroundStyle(.white)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(message)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text("Your changes are safe on this iPhone. Tap for diagnostics.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.red.gradient, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 6)
|
||||
.sheet(isPresented: $showingDiagnostics) {
|
||||
NavigationStack { DiagnosticsView() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.default, value: sync.writeQueueState)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
//
|
||||
// SplitItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 2:45 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SplitItem: View {
|
||||
var split: Split
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Golden ratio rectangle (1:1.618)
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [splitColor, splitColor.darker(by: 0.2)]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.aspectRatio(1.618, contentMode: .fit)
|
||||
.shadow(radius: 2)
|
||||
|
||||
GeometryReader { geo in
|
||||
VStack(spacing: 4) {
|
||||
Spacer()
|
||||
|
||||
// Icon in the center - using dynamic sizing
|
||||
Image(systemName: split.systemImage)
|
||||
.font(.system(size: min(geo.size.width * 0.3, 40), weight: .bold))
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: geo.size.width * 0.6, maxHeight: geo.size.height * 0.4)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Name at the bottom inside the rectangle
|
||||
Text(split.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var splitColor: Color {
|
||||
Color.color(from: split.color)
|
||||
}
|
||||
}
|
||||
@@ -10,39 +10,37 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Splits library screen, pushed from Settings → Library: a two-column grid of
|
||||
/// split tiles. Tapping a tile opens `SplitDetailView`; long-pressing offers Delete
|
||||
/// (the sole split-delete affordance); the toolbar's + adds a new split. Starter
|
||||
/// splits are seeded automatically on the true first run (`SyncEngine.autoSeedIfEmpty`),
|
||||
/// so an empty library just invites creating one.
|
||||
/// The Splits library screen, pushed from Settings → Library: a plain list of
|
||||
/// splits, one row each. Tapping a row opens `SplitDetailView`; long-pressing
|
||||
/// offers Delete (the sole split-delete affordance); the toolbar's + adds a new
|
||||
/// split. Starter splits are seeded automatically on the true first run
|
||||
/// (`SyncEngine.autoSeedIfEmpty`), so an empty library just invites creating one.
|
||||
struct SplitListView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
|
||||
@Query(sort: \Split.order) private var splits: [Split]
|
||||
@Query(sort: \Split.name) private var splits: [Split]
|
||||
|
||||
@State private var showingAddSheet = false
|
||||
@State private var splitToDelete: Split?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||
ForEach(splits) { split in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
List {
|
||||
ForEach(splits) { split in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
SplitRow(split: split)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
splitToDelete = split
|
||||
} label: {
|
||||
SplitItem(split: split)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
splitToDelete = split
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.overlay {
|
||||
if splits.isEmpty {
|
||||
ContentUnavailableView(
|
||||
@@ -87,3 +85,34 @@ struct SplitListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single split row: a small rounded-square badge (the split's color at low
|
||||
/// opacity, its symbol tinted full-strength) beside the split name and its
|
||||
/// exercise count.
|
||||
private struct SplitRow: View {
|
||||
var split: Split
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: split.systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(splitColor)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(splitColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(split.name)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var splitColor: Color {
|
||||
Color.color(from: split.color)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,6 @@ private struct WorkoutLogRow: View {
|
||||
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
|
||||
/// strength and the × dimmed, so the counts read at a glance.
|
||||
private var setsAndReps: Text {
|
||||
let times = Text(" × ").foregroundStyle(.secondary)
|
||||
let count: String
|
||||
if loadType == .duration {
|
||||
let mins = log.durationSeconds / 60
|
||||
@@ -476,7 +475,7 @@ private struct WorkoutLogRow: View {
|
||||
} else {
|
||||
count = "\(log.reps)"
|
||||
}
|
||||
return Text("\(log.sets)") + times + Text(count)
|
||||
return Text("\(log.sets)\(Text(" × ").foregroundStyle(.secondary))\(count)")
|
||||
}
|
||||
|
||||
private var showsWeight: Bool { loadType == .weight }
|
||||
|
||||
@@ -61,6 +61,11 @@ struct WorkoutLogsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Workout Logs")
|
||||
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
|
||||
// On the root screen so a stalled save is visible where editing happens.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
SyncStatusBanner()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
@@ -152,24 +157,70 @@ struct SplitPickerSheet: View {
|
||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||
}
|
||||
|
||||
/// The splits most recently trained, newest first, each with the start date
|
||||
/// of its latest workout — derived from the workout log (`workouts` is
|
||||
/// already sorted by start descending). Each workout's `splitID` follows the
|
||||
/// seed clone-on-edit redirect so a workout started from a since-forked seed
|
||||
/// still credits the live clone. Deduped, capped, and limited to splits that
|
||||
/// still exist.
|
||||
private var recentSplits: [(split: Split, lastStart: Date)] {
|
||||
var seen = Set<String>()
|
||||
var recents: [(split: Split, lastStart: Date)] = []
|
||||
for workout in workouts {
|
||||
guard let splitID = workout.splitID else { continue }
|
||||
let liveID = sync.currentSplitID(for: splitID)
|
||||
guard !seen.contains(liveID) else { continue }
|
||||
seen.insert(liveID)
|
||||
if let split = splits.first(where: { $0.id == liveID }) {
|
||||
recents.append((split, workout.start))
|
||||
if recents.count == 3 { break }
|
||||
}
|
||||
}
|
||||
return recents
|
||||
}
|
||||
|
||||
/// One selectable split row, shared by the Recent and All Splits sections.
|
||||
/// Recent rows pass `lastTrained` to show how long ago the split was run.
|
||||
private func splitRow(_ split: Split, lastTrained: Date? = nil) -> some View {
|
||||
Button {
|
||||
confirmAndStart(with: split)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: split.systemImage)
|
||||
.foregroundColor(Color.color(from: split.color))
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(split.name)
|
||||
if let lastTrained {
|
||||
Text(lastTrained.daysAgoLabel())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(splits) { split in
|
||||
Button {
|
||||
confirmAndStart(with: split)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: split.systemImage)
|
||||
.foregroundColor(Color.color(from: split.color))
|
||||
Text(split.name)
|
||||
Spacer()
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
if !recentSplits.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(recentSplits, id: \.split.id) { recent in
|
||||
splitRow(recent.split, lastTrained: recent.lastStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(recentSplits.isEmpty ? "" : "All Splits") {
|
||||
ForEach(splits) { split in
|
||||
splitRow(split)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select a Split")
|
||||
.toolbar {
|
||||
|
||||
Reference in New Issue
Block a user