diff --git a/Features/Backup/AppMenuView.swift b/Features/Backup/AppMenuView.swift index 9ebc817..57170c0 100644 --- a/Features/Backup/AppMenuView.swift +++ b/Features/Backup/AppMenuView.swift @@ -3,16 +3,22 @@ import SwiftUI struct AppMenuView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor + @EnvironmentObject var engine: BackupEngine @Environment(\.dismiss) private var dismiss - @State private var showClearConfirm = false + @State private var showClearSheet = false @State private var selectedErrorEntry: BackupHistoryEntry? private var errorEntries: [BackupHistoryEntry] { store.historyEntries.filter { $0.failedCount > 0 } } - // Wi-Fi state helpers + private var lastSuccessfulEntry: BackupHistoryEntry? { + store.historyEntries.first { $0.failedCount == 0 } + } + + // MARK: — Wi-Fi helpers + private var wifiSubtitle: String { if lanMonitor.isOnCellular { return "Cellular only" } if lanMonitor.isOnNetwork { return lanMonitor.currentSSID ?? "Connected" } @@ -26,12 +32,13 @@ struct AppMenuView: View { } private var wifiBadgeColor: Color { - if lanMonitor.isOnCellular { return Color(red: 1.0, green: 0.58, blue: 0.0) } + if lanMonitor.isOnCellular { return .orange } if lanMonitor.isOnNetwork { return AppTheme.positive } - return AppTheme.inkQuaternary + return .orange } - // NAS badge — green when reachable, orange when offline, muted when unknown + // MARK: — NAS helpers + private var nasBadgeLabel: String { switch lanMonitor.nasReachable { case true: return "Connected" @@ -43,11 +50,41 @@ struct AppMenuView: View { private var nasBadgeColor: Color { switch lanMonitor.nasReachable { case true: return AppTheme.positive - case false: return Color(red: 1.0, green: 0.58, blue: 0.0) + case false: return .orange default: return AppTheme.inkQuaternary } } + // MARK: — Session helpers + + private var sessionColor: Color { + switch engine.job.status { + case .running: return AppTheme.interactive + case .paused: return .orange + default: return .orange + } + } + + private var sessionTitle: String { + switch engine.job.status { + case .preparing: return "Scanning library…" + case .running: return "Uploading" + case .paused: return "Paused" + default: return "Active" + } + } + + private var sessionBadgeLabel: String { + switch engine.job.status { + case .preparing: return "Scanning" + case .running: return "Uploading" + case .paused: return "Paused" + default: return "Active" + } + } + + // MARK: — Body + var body: some View { NavigationStack { ZStack { @@ -56,11 +93,18 @@ struct AppMenuView: View { ScrollView { VStack(spacing: AppTheme.sectionGap) { connectionStatusSection - syncActivitySection - historySection + + if engine.job.status.isActive { + currentSessionSection + } + if !errorEntries.isEmpty { errorsSection } + + lastBackupSection + syncActivitySection + historySection aboutSection } .padding(.horizontal, AppTheme.hPad) @@ -77,15 +121,13 @@ struct AppMenuView: View { .foregroundStyle(AppTheme.ink) } } - .alert("Clear History", isPresented: $showClearConfirm) { - Button("Clear", role: .destructive) { store.clearHistory() } - Button("Cancel", role: .cancel) {} - } message: { - if errorEntries.isEmpty { - Text("All \(store.historyEntries.count) backup records will be removed.") - } else { - Text("All \(store.historyEntries.count) backup records will be removed, including \(errorEntries.count) error\(errorEntries.count == 1 ? "" : "s").") + .confirmationDialog("Clear Activity", isPresented: $showClearSheet, titleVisibility: .visible) { + Button("Clear Completed Logs") { store.clearCompletedHistory() } + if !errorEntries.isEmpty { + Button("Clear Errors") { store.clearErrorHistory() } + Button("Clear Everything", role: .destructive) { store.clearHistory() } } + Button("Cancel", role: .cancel) {} } .sheet(item: $selectedErrorEntry) { entry in errorDetailSheet(entry: entry) @@ -100,7 +142,7 @@ struct AppMenuView: View { menuSectionLabel("CONNECTION STATUS") VStack(spacing: 0) { - // NAS row + // NAS / destination row HStack(spacing: 12) { iconBadge("server.rack", color: AppTheme.inkSecondary) @@ -109,9 +151,16 @@ struct AppMenuView: View { .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) if let conn = store.savedConnection { - Text("\(conn.nasProtocol.rawValue) · \(conn.host)") + Text("\(conn.nasProtocol.rawValue.uppercased()) · \(conn.host)") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) + if conn.remotePath != "/" { + Text(conn.remotePath) + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkQuaternary) + .lineLimit(1) + .truncationMode(.middle) + } } } @@ -124,7 +173,7 @@ struct AppMenuView: View { menuHairline - // Wi-Fi row — title is always "Wi-Fi"; subtitle shows SSID or connection type + // Wi-Fi row HStack(spacing: 12) { iconBadge("wifi", color: AppTheme.inkSecondary) @@ -165,6 +214,182 @@ struct AppMenuView: View { } } + // MARK: — Current Session + + private var currentSessionSection: some View { + VStack(alignment: .leading, spacing: 8) { + menuSectionLabel("CURRENT SESSION") + + VStack(spacing: 0) { + HStack(spacing: 12) { + iconBadge( + engine.job.status == .running ? "arrow.up.circle" : + engine.job.status == .paused ? "pause.circle" : "arrow.triangle.2.circlepath", + color: sessionColor + ) + + VStack(alignment: .leading, spacing: 2) { + Text(sessionTitle) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) + if engine.job.status == .running, + !engine.job.currentFileName.isEmpty { + Text(engine.job.currentFileName) + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer() + + statusBadge(label: sessionBadgeLabel, color: sessionColor) + } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 14) + + if engine.job.totalFiles > 0 { + menuHairline + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) + Spacer() + if engine.job.speedBytesPerSecond > 1000 { + Text(speedString(engine.job.speedBytesPerSecond)) + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + } + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(AppTheme.inkQuaternary.opacity(0.35)) + .frame(height: 4) + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(sessionColor) + .frame(width: max(0, geo.size.width * engine.job.progress), height: 4) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) + } + } + .frame(height: 4) + + if let eta = engine.job.eta, eta > 5 { + Text("About \(etaString(eta)) remaining") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + } + } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 12) + } + } + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + } + } + + // MARK: — Last Successful Backup + + private var lastBackupSection: some View { + VStack(alignment: .leading, spacing: 8) { + menuSectionLabel("LAST SUCCESSFUL BACKUP") + + if let entry = lastSuccessfulEntry { + VStack(spacing: 0) { + HStack(spacing: 12) { + iconBadge("checkmark.circle", color: AppTheme.positive) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) + let totalSafe = entry.uploadedCount + entry.skippedCount + Text("\(totalSafe) photo\(totalSafe == 1 ? "" : "s") safe") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + } + + Spacer() + + statusBadge(label: "Completed", color: AppTheme.positive) + } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 14) + } + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + } else { + emptyCard(icon: "checkmark.circle", text: "No successful backups yet") + } + } + } + + // MARK: — Errors + + private var errorsSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + menuSectionLabel("ERRORS") + Spacer() + if !engine.job.status.isActive { + Button { + retryBackup() + } label: { + Label("Retry Failed", systemImage: "arrow.clockwise") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AppTheme.interactive) + } + } + } + + VStack(spacing: 0) { + ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in + Button { + selectedErrorEntry = entry + } label: { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(AppTheme.destructive) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.ink) + Text("\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s") failed · \(entry.uploadedCount) uploaded") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.destructive.opacity(0.8)) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) + } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 12) + } + .buttonStyle(ScaleButtonStyle()) + + if idx < min(errorEntries.count, 3) - 1 { + menuHairline + } + } + } + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + } + } + // MARK: — Sync Activity private var syncActivitySection: some View { @@ -173,26 +398,31 @@ struct AppMenuView: View { menuSectionLabel("SYNC ACTIVITY") Spacer() if !store.historyEntries.isEmpty { - Button("Clear") { showClearConfirm = true } - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(AppTheme.inkTertiary) + Button { + showClearSheet = true + } label: { + Text("Clear ▾") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.inkTertiary) + } } } VStack(spacing: 0) { let total = store.historyEntries.count let uploaded = store.historyEntries.reduce(0) { $0 + $1.uploadedCount } + let safe = store.historyEntries.reduce(0) { $0 + $1.skippedCount } let errors = store.historyEntries.reduce(0) { $0 + $1.failedCount } - activityStat(icon: "clock.arrow.circlepath", label: "Total backups", value: "\(total)") - + activityStat(icon: "clock.arrow.circlepath", label: "Backups completed", value: "\(total)") menuHairline - - activityStat(icon: "arrow.up.doc", label: "Files uploaded", value: "\(uploaded)") + activityStat(icon: "arrow.up.doc", label: "New uploads", value: "\(uploaded)") + menuHairline + activityStat(icon: "checkmark", label: "Already safe", value: "\(safe)") if errors > 0 { menuHairline - activityStat(icon: "exclamationmark.triangle", label: "Total errors", value: "\(errors)", valueColor: AppTheme.destructive) + activityStat(icon: "exclamationmark.triangle", label: "Errors", value: "\(errors)", valueColor: AppTheme.destructive) } } .background(AppTheme.surfaceRaised) @@ -240,12 +470,10 @@ struct AppMenuView: View { .frame(width: 5, height: 5) VStack(alignment: .leading, spacing: 2) { - Text(entry.date, format: .dateTime.month(.abbreviated).day().year().hour().minute()) + Text(entry.date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) .font(AppTheme.caption()) .foregroundStyle(AppTheme.ink) - Text("\(entry.uploadedCount) uploaded · \(entry.skippedCount) skipped") - .font(AppTheme.micro()) - .foregroundStyle(AppTheme.inkTertiary) + historySubtitle(entry) } Spacer() @@ -266,50 +494,24 @@ struct AppMenuView: View { .padding(.vertical, 12) } - // MARK: — Errors - - private var errorsSection: some View { - VStack(alignment: .leading, spacing: 8) { - menuSectionLabel("ERRORS") - - VStack(spacing: 0) { - ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in - Button { - selectedErrorEntry = entry - } label: { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(AppTheme.destructive) - - VStack(alignment: .leading, spacing: 2) { - Text(entry.date, format: .dateTime.month(.abbreviated).day().year()) - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.ink) - Text("\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s") failed") - .font(AppTheme.micro()) - .foregroundStyle(AppTheme.destructive.opacity(0.8)) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(AppTheme.inkQuaternary) - } - .padding(.horizontal, AppTheme.cardPad) - .padding(.vertical, 12) - } - .buttonStyle(ScaleButtonStyle()) - - if idx < min(errorEntries.count, 3) - 1 { - menuHairline - } - } - } - .background(AppTheme.surfaceRaised) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) - .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + @ViewBuilder + private func historySubtitle(_ entry: BackupHistoryEntry) -> some View { + if entry.failedCount > 0 { + Text("\(entry.uploadedCount) uploaded · \(entry.failedCount) failed") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.destructive.opacity(0.8)) + } else if entry.uploadedCount == 0 && entry.skippedCount > 0 { + Text("\(entry.skippedCount) already backed up") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + } else if entry.skippedCount > 0 { + Text("\(entry.uploadedCount) new · \(entry.skippedCount) already safe") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + } else { + Text("\(entry.uploadedCount) photo\(entry.uploadedCount == 1 ? "" : "s") backed up") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) } } @@ -322,6 +524,7 @@ struct AppMenuView: View { AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: AppTheme.sectionGap) { + // Summary card VStack(spacing: 0) { detailRow(label: "Date", value: entry.date.formatted(.dateTime.month(.wide).day().year().hour().minute())) menuHairline @@ -329,7 +532,7 @@ struct AppMenuView: View { menuHairline detailRow(label: "Uploaded", value: "\(entry.uploadedCount) file\(entry.uploadedCount == 1 ? "" : "s")") menuHairline - detailRow(label: "Skipped", value: "\(entry.skippedCount) file\(entry.skippedCount == 1 ? "" : "s")") + detailRow(label: "Already safe", value: "\(entry.skippedCount) file\(entry.skippedCount == 1 ? "" : "s")") menuHairline detailRow(label: "Duration", value: durationString(entry.durationSeconds)) menuHairline @@ -341,18 +544,47 @@ struct AppMenuView: View { .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + // Suggested fix VStack(alignment: .leading, spacing: 8) { menuSectionLabel("SUGGESTED FIX") VStack(alignment: .leading, spacing: 10) { fixRow(icon: "network", text: "Ensure the device is on the same network as the NAS.") fixRow(icon: "internaldrive", text: "Check that the NAS has enough available storage space.") - fixRow(icon: "arrow.clockwise", text: "Retry the backup — transient network errors often resolve automatically.") + fixRow(icon: "arrow.clockwise", text: "Retry — already-uploaded photos are skipped automatically.") } .padding(AppTheme.cardPad) .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } + + // Retry button + Button { + selectedErrorEntry = nil + retryBackup() + } label: { + Label("Retry Failed Uploads", systemImage: "arrow.clockwise") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + lanMonitor.nasReachable == true + ? AppTheme.interactive + : AppTheme.inkQuaternary + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .disabled(lanMonitor.nasReachable != true || engine.job.status.isActive) + .buttonStyle(ScaleButtonStyle()) + + if lanMonitor.nasReachable != true { + Text("NAS is not reachable — connect to the same network first.") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, -4) + } } .padding(.horizontal, AppTheme.hPad) .padding(.top, 8) @@ -371,42 +603,6 @@ struct AppMenuView: View { } } - private func detailRow(label: String, value: String, valueColor: Color = AppTheme.ink) -> some View { - HStack { - Text(label) - .font(AppTheme.body()) - .foregroundStyle(AppTheme.inkTertiary) - Spacer() - Text(value) - .font(AppTheme.body()) - .foregroundStyle(valueColor) - .multilineTextAlignment(.trailing) - } - .padding(.horizontal, AppTheme.cardPad) - .padding(.vertical, 13) - } - - private func fixRow(icon: String, text: String) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: icon) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(AppTheme.inkSecondary) - .frame(width: 16, height: 16) - .padding(.top, 1) - Text(text) - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func durationString(_ seconds: Double) -> String { - if seconds < 60 { return "\(Int(seconds))s" } - let m = Int(seconds) / 60 - let s = Int(seconds) % 60 - return s == 0 ? "\(m)m" : "\(m)m \(s)s" - } - // MARK: — About private var aboutSection: some View { @@ -415,11 +611,6 @@ struct AppMenuView: View { VStack(spacing: 0) { HStack(spacing: 12) { - Image("ugreen_logo") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) Text("kisani.") .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(AppTheme.ink) @@ -461,6 +652,14 @@ struct AppMenuView: View { } } + // MARK: — Actions + + private func retryBackup() { + guard let conn = store.savedConnection, !engine.job.status.isActive else { return } + dismiss() + Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } + } + // MARK: — Helpers private func menuSectionLabel(_ text: String) -> some View { @@ -536,4 +735,55 @@ struct AppMenuView: View { .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } + + private func detailRow(label: String, value: String, valueColor: Color = AppTheme.ink) -> some View { + HStack { + Text(label) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.inkTertiary) + Spacer() + Text(value) + .font(AppTheme.body()) + .foregroundStyle(valueColor) + .multilineTextAlignment(.trailing) + } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 13) + } + + private func fixRow(icon: String, text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 16, height: 16) + .padding(.top, 1) + Text(text) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func speedString(_ bytesPerSecond: Double) -> String { + if bytesPerSecond >= 1_048_576 { + return String(format: "%.1f MB/s", bytesPerSecond / 1_048_576) + } + return String(format: "%.0f KB/s", bytesPerSecond / 1_024) + } + + private func etaString(_ seconds: TimeInterval) -> String { + let mins = Int(seconds) / 60 + let secs = Int(seconds) % 60 + if mins >= 60 { return "\(mins / 60)h \(mins % 60)m" } + if mins > 0 { return "\(mins) min" } + return "\(secs)s" + } + + private func durationString(_ seconds: Double) -> String { + let m = Int(seconds) / 60 + let s = Int(seconds) % 60 + if seconds < 60 { return "\(Int(seconds))s" } + return s == 0 ? "\(m)m" : "\(m)m \(s)s" + } } diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift index 9c467b0..9c8993d 100644 --- a/Shared/Persistence/ConnectionStore.swift +++ b/Shared/Persistence/ConnectionStore.swift @@ -110,6 +110,16 @@ final class ConnectionStore: ObservableObject { UserDefaults.standard.removeObject(forKey: "historyEntries") } + func clearCompletedHistory() { + historyEntries.removeAll { $0.failedCount == 0 } + saveUD(historyEntries, key: "historyEntries") + } + + func clearErrorHistory() { + historyEntries.removeAll { $0.failedCount > 0 } + saveUD(historyEntries, key: "historyEntries") + } + /// Locks the screen — keeps the saved connection but requires Face ID again. func lock() { isSessionActive = false