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:
@@ -83,6 +83,10 @@ actor CanonicalSyncService {
|
|||||||
throw SyncError.syncAlreadyInProgress
|
throw SyncError.syncAlreadyInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.syncStarted()
|
||||||
|
#endif
|
||||||
|
|
||||||
// Check if sync is enabled
|
// Check if sync is enabled
|
||||||
guard syncState.syncEnabled else {
|
guard syncState.syncEnabled else {
|
||||||
return SyncResult(
|
return SyncResult(
|
||||||
@@ -124,6 +128,9 @@ actor CanonicalSyncService {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
// Sync in dependency order, checking cancellation between each entity type
|
// Sync in dependency order, checking cancellation between each entity type
|
||||||
|
|
||||||
|
// Stadium sync
|
||||||
|
var entityStartTime = Date()
|
||||||
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
||||||
@@ -133,11 +140,16 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat1
|
totalSkippedIncompatible += skipIncompat1
|
||||||
totalSkippedOlder += skipOlder1
|
totalSkippedOlder += skipOlder1
|
||||||
syncState.lastStadiumSync = Date()
|
syncState.lastStadiumSync = Date()
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
if try saveProgressAndCheckCancellation() {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// League Structure sync
|
||||||
|
entityStartTime = Date()
|
||||||
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
||||||
@@ -147,11 +159,16 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat2
|
totalSkippedIncompatible += skipIncompat2
|
||||||
totalSkippedOlder += skipOlder2
|
totalSkippedOlder += skipOlder2
|
||||||
syncState.lastLeagueStructureSync = Date()
|
syncState.lastLeagueStructureSync = Date()
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
if try saveProgressAndCheckCancellation() {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Team sync
|
||||||
|
entityStartTime = Date()
|
||||||
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
|
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync,
|
||||||
@@ -161,11 +178,16 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat3
|
totalSkippedIncompatible += skipIncompat3
|
||||||
totalSkippedOlder += skipOlder3
|
totalSkippedOlder += skipOlder3
|
||||||
syncState.lastTeamSync = Date()
|
syncState.lastTeamSync = Date()
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
if try saveProgressAndCheckCancellation() {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Team Alias sync
|
||||||
|
entityStartTime = Date()
|
||||||
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
|
||||||
@@ -175,11 +197,16 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat4
|
totalSkippedIncompatible += skipIncompat4
|
||||||
totalSkippedOlder += skipOlder4
|
totalSkippedOlder += skipOlder4
|
||||||
syncState.lastTeamAliasSync = Date()
|
syncState.lastTeamAliasSync = Date()
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
if try saveProgressAndCheckCancellation() {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stadium Alias sync
|
||||||
|
entityStartTime = Date()
|
||||||
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
|
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
|
||||||
@@ -189,11 +216,16 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat5
|
totalSkippedIncompatible += skipIncompat5
|
||||||
totalSkippedOlder += skipOlder5
|
totalSkippedOlder += skipOlder5
|
||||||
syncState.lastStadiumAliasSync = Date()
|
syncState.lastStadiumAliasSync = Date()
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
if try saveProgressAndCheckCancellation() {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Game sync
|
||||||
|
entityStartTime = Date()
|
||||||
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
||||||
@@ -203,11 +235,16 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat6
|
totalSkippedIncompatible += skipIncompat6
|
||||||
totalSkippedOlder += skipOlder6
|
totalSkippedOlder += skipOlder6
|
||||||
syncState.lastGameSync = Date()
|
syncState.lastGameSync = Date()
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
if try saveProgressAndCheckCancellation() {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sport sync
|
||||||
|
entityStartTime = Date()
|
||||||
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
||||||
@@ -217,6 +254,9 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat7
|
totalSkippedIncompatible += skipIncompat7
|
||||||
totalSkippedOlder += skipOlder7
|
totalSkippedOlder += skipOlder7
|
||||||
syncState.lastSportSync = Date()
|
syncState.lastSportSync = Date()
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
|
#endif
|
||||||
|
|
||||||
// Mark sync successful - clear per-entity timestamps since full sync completed
|
// Mark sync successful - clear per-entity timestamps since full sync completed
|
||||||
syncState.syncInProgress = false
|
syncState.syncInProgress = false
|
||||||
@@ -234,12 +274,20 @@ actor CanonicalSyncService {
|
|||||||
|
|
||||||
try context.save()
|
try context.save()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.syncCompleted(totalDuration: Date().timeIntervalSince(startTime))
|
||||||
|
#endif
|
||||||
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
// Graceful cancellation - progress already saved
|
// Graceful cancellation - progress already saved
|
||||||
syncState.syncInProgress = false
|
syncState.syncInProgress = false
|
||||||
syncState.lastSyncError = "Sync cancelled - partial progress saved"
|
syncState.lastSyncError = "Sync cancelled - partial progress saved"
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.syncFailed(error: CancellationError())
|
||||||
|
#endif
|
||||||
|
|
||||||
return SyncResult(
|
return SyncResult(
|
||||||
stadiumsUpdated: totalStadiums,
|
stadiumsUpdated: totalStadiums,
|
||||||
teamsUpdated: totalTeams,
|
teamsUpdated: totalTeams,
|
||||||
@@ -266,6 +314,11 @@ actor CanonicalSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.syncFailed(error: error)
|
||||||
|
#endif
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
SportsTime/Core/Services/SyncStatusMonitor.swift
Normal file
152
SportsTime/Core/Services/SyncStatusMonitor.swift
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//
|
||||||
|
// SyncStatusMonitor.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// In-memory sync status tracking for debug builds.
|
||||||
|
// Shows per-entity sync status in Settings.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
|
||||||
|
// MARK: - Entity Types
|
||||||
|
|
||||||
|
enum SyncEntityType: String, CaseIterable, Identifiable {
|
||||||
|
case stadium = "Stadium"
|
||||||
|
case leagueStructure = "League Structure"
|
||||||
|
case team = "Team"
|
||||||
|
case teamAlias = "Team Alias"
|
||||||
|
case stadiumAlias = "Stadium Alias"
|
||||||
|
case game = "Game"
|
||||||
|
case sport = "Sport"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .stadium: return "building.2"
|
||||||
|
case .leagueStructure: return "list.bullet.indent"
|
||||||
|
case .team: return "person.3"
|
||||||
|
case .teamAlias: return "person.text.rectangle"
|
||||||
|
case .stadiumAlias: return "building"
|
||||||
|
case .game: return "sportscourt"
|
||||||
|
case .sport: return "figure.run"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entity Sync Status
|
||||||
|
|
||||||
|
struct EntitySyncStatus: Identifiable {
|
||||||
|
let id: SyncEntityType
|
||||||
|
let entityType: SyncEntityType
|
||||||
|
let lastSyncTime: Date?
|
||||||
|
let isSuccess: Bool
|
||||||
|
let recordCount: Int
|
||||||
|
let errorMessage: String?
|
||||||
|
let duration: TimeInterval?
|
||||||
|
|
||||||
|
init(
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
lastSyncTime: Date? = nil,
|
||||||
|
isSuccess: Bool = false,
|
||||||
|
recordCount: Int = 0,
|
||||||
|
errorMessage: String? = nil,
|
||||||
|
duration: TimeInterval? = nil
|
||||||
|
) {
|
||||||
|
self.id = entityType
|
||||||
|
self.entityType = entityType
|
||||||
|
self.lastSyncTime = lastSyncTime
|
||||||
|
self.isSuccess = isSuccess
|
||||||
|
self.recordCount = recordCount
|
||||||
|
self.errorMessage = errorMessage
|
||||||
|
self.duration = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status that hasn't been synced yet
|
||||||
|
static func pending(_ entityType: SyncEntityType) -> EntitySyncStatus {
|
||||||
|
EntitySyncStatus(entityType: entityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status Monitor
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SyncStatusMonitor {
|
||||||
|
static let shared = SyncStatusMonitor()
|
||||||
|
|
||||||
|
private(set) var entityStatuses: [SyncEntityType: EntitySyncStatus] = [:]
|
||||||
|
private(set) var overallSyncInProgress: Bool = false
|
||||||
|
private(set) var lastFullSyncTime: Date?
|
||||||
|
private(set) var lastFullSyncDuration: TimeInterval?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Initialize all entities with pending status
|
||||||
|
for entityType in SyncEntityType.allCases {
|
||||||
|
entityStatuses[entityType] = .pending(entityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status Updates
|
||||||
|
|
||||||
|
/// Called when a full sync cycle begins
|
||||||
|
func syncStarted() {
|
||||||
|
overallSyncInProgress = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called after each entity sync completes
|
||||||
|
func updateEntityStatus(
|
||||||
|
_ entityType: SyncEntityType,
|
||||||
|
success: Bool,
|
||||||
|
recordCount: Int,
|
||||||
|
duration: TimeInterval,
|
||||||
|
errorMessage: String? = nil
|
||||||
|
) {
|
||||||
|
entityStatuses[entityType] = EntitySyncStatus(
|
||||||
|
entityType: entityType,
|
||||||
|
lastSyncTime: Date(),
|
||||||
|
isSuccess: success,
|
||||||
|
recordCount: recordCount,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
duration: duration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the full sync cycle completes
|
||||||
|
func syncCompleted(totalDuration: TimeInterval) {
|
||||||
|
overallSyncInProgress = false
|
||||||
|
lastFullSyncTime = Date()
|
||||||
|
lastFullSyncDuration = totalDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when sync fails before completing
|
||||||
|
func syncFailed(error: Error) {
|
||||||
|
overallSyncInProgress = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
/// All entity statuses in sync order
|
||||||
|
var orderedStatuses: [EntitySyncStatus] {
|
||||||
|
SyncEntityType.allCases.compactMap { entityStatuses[$0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether all synced entities succeeded
|
||||||
|
var allSuccessful: Bool {
|
||||||
|
entityStatuses.values.allSatisfy { $0.isSuccess || $0.lastSyncTime == nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of entities that failed
|
||||||
|
var failedCount: Int {
|
||||||
|
entityStatuses.values.filter { !$0.isSuccess && $0.lastSyncTime != nil }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total records synced across all entities
|
||||||
|
var totalRecordsSynced: Int {
|
||||||
|
entityStatuses.values.reduce(0) { $0 + $1.recordCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -7,10 +7,14 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
@State private var viewModel = SettingsViewModel()
|
@State private var viewModel = SettingsViewModel()
|
||||||
@State private var showResetConfirmation = false
|
@State private var showResetConfirmation = false
|
||||||
@State private var showPaywall = false
|
@State private var showPaywall = false
|
||||||
@State private var showOnboardingPaywall = false
|
@State private var showOnboardingPaywall = false
|
||||||
|
#if DEBUG
|
||||||
|
@State private var selectedSyncStatus: EntitySyncStatus?
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -41,6 +45,9 @@ struct SettingsView: View {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Debug
|
// Debug
|
||||||
debugSection
|
debugSection
|
||||||
|
|
||||||
|
// Sync Status
|
||||||
|
syncStatusSection
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
@@ -339,6 +346,103 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
.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
|
#endif
|
||||||
|
|
||||||
// MARK: - Subscription Section
|
// MARK: - Subscription Section
|
||||||
@@ -430,3 +534,136 @@ struct SettingsView: View {
|
|||||||
SettingsView()
|
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