feat(debug): add per-entity CloudKit sync status in Settings
Add debug-only sync status monitoring to help diagnose CloudKit sync issues. Shows last sync time, success/failure, and record counts for each entity type. Includes manual sync trigger and re-enable button when sync is paused. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,14 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@State private var showResetConfirmation = false
|
||||
@State private var showPaywall = false
|
||||
@State private var showOnboardingPaywall = false
|
||||
#if DEBUG
|
||||
@State private var selectedSyncStatus: EntitySyncStatus?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -41,6 +45,9 @@ struct SettingsView: View {
|
||||
#if DEBUG
|
||||
// Debug
|
||||
debugSection
|
||||
|
||||
// Sync Status
|
||||
syncStatusSection
|
||||
#endif
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
@@ -339,6 +346,103 @@ struct SettingsView: View {
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
private var syncStatusSection: some View {
|
||||
Section {
|
||||
// Check if sync is disabled
|
||||
let syncState = SyncState.current(in: modelContext)
|
||||
if !syncState.syncEnabled {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Sync Paused")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let reason = syncState.syncPausedReason {
|
||||
Text(reason)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastError = syncState.lastSyncError {
|
||||
Text("Last error: \(lastError)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Text("Consecutive failures: \(syncState.consecutiveFailures)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
let syncService = CanonicalSyncService()
|
||||
await syncService.resumeSync(context: modelContext)
|
||||
print("[SyncDebug] Sync re-enabled by user")
|
||||
}
|
||||
} label: {
|
||||
Label("Re-enable Sync", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
// Overall status header
|
||||
if SyncStatusMonitor.shared.overallSyncInProgress {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Sync in progress...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if let lastSync = SyncStatusMonitor.shared.lastFullSyncTime {
|
||||
HStack {
|
||||
Image(systemName: SyncStatusMonitor.shared.allSuccessful ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(SyncStatusMonitor.shared.allSuccessful ? .green : .orange)
|
||||
Text("Last sync: \(lastSync.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if let duration = SyncStatusMonitor.shared.lastFullSyncDuration {
|
||||
Text("(\(String(format: "%.1fs", duration)))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-entity status rows
|
||||
ForEach(SyncStatusMonitor.shared.orderedStatuses) { status in
|
||||
SyncStatusRow(status: status) {
|
||||
selectedSyncStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
// Manual sync trigger
|
||||
Button {
|
||||
Task {
|
||||
let syncService = CanonicalSyncService()
|
||||
print("[SyncDebug] Manual sync triggered by user")
|
||||
do {
|
||||
let result = try await syncService.syncAll(context: modelContext)
|
||||
print("[SyncDebug] Manual sync completed: \(result.totalUpdated) records")
|
||||
} catch {
|
||||
print("[SyncDebug] Manual sync failed: \(error)")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Trigger Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
} header: {
|
||||
Text("Sync Status")
|
||||
} footer: {
|
||||
Text("Shows CloudKit sync status for each record type. Updated on app foreground.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
.sheet(item: $selectedSyncStatus) { status in
|
||||
SyncStatusDetailSheet(status: status)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Subscription Section
|
||||
@@ -430,3 +534,136 @@ struct SettingsView: View {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debug Sync Status Views
|
||||
|
||||
#if DEBUG
|
||||
|
||||
/// A row showing sync status for a single entity type
|
||||
struct SyncStatusRow: View {
|
||||
let status: EntitySyncStatus
|
||||
let onInfoTapped: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Status indicator
|
||||
Image(systemName: statusIcon)
|
||||
.foregroundStyle(statusColor)
|
||||
.font(.system(size: 14))
|
||||
.frame(width: 20)
|
||||
|
||||
// Entity icon and name
|
||||
Image(systemName: status.entityType.iconName)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: 14))
|
||||
.frame(width: 20)
|
||||
|
||||
Text(status.entityType.rawValue)
|
||||
.font(.body)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Record count (if synced)
|
||||
if status.lastSyncTime != nil {
|
||||
Text("\(status.recordCount)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Info button
|
||||
Button {
|
||||
onInfoTapped()
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusIcon: String {
|
||||
guard status.lastSyncTime != nil else {
|
||||
return "circle.dotted"
|
||||
}
|
||||
return status.isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill"
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
guard status.lastSyncTime != nil else {
|
||||
return .secondary
|
||||
}
|
||||
return status.isSuccess ? .green : .red
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail sheet showing full sync information for an entity
|
||||
struct SyncStatusDetailSheet: View {
|
||||
let status: EntitySyncStatus
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
DetailRow(label: "Entity Type", value: status.entityType.rawValue)
|
||||
DetailRow(label: "Status", value: status.isSuccess ? "Success" : "Failed", valueColor: status.isSuccess ? .green : .red)
|
||||
}
|
||||
|
||||
Section {
|
||||
if let syncTime = status.lastSyncTime {
|
||||
DetailRow(label: "Last Sync", value: syncTime.formatted(date: .abbreviated, time: .standard))
|
||||
} else {
|
||||
DetailRow(label: "Last Sync", value: "Never")
|
||||
}
|
||||
|
||||
DetailRow(label: "Records Synced", value: "\(status.recordCount)")
|
||||
|
||||
if let duration = status.duration {
|
||||
DetailRow(label: "Duration", value: String(format: "%.2f seconds", duration))
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage = status.errorMessage {
|
||||
Section("Error") {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(status.entityType.rawValue)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper view for detail rows
|
||||
private struct DetailRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var valueColor: Color = .primary
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(valueColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user