Rebuild Activity screen as a live backup activity center
Section order: Connection Status → Current Session (live, when active) → Errors → Last Successful Backup → Sync Activity → History → About Connection Status: - NAS row shows remotePath as third subtitle line (destination trust signal) - Wi-Fi Offline badge now orange (was muted grey) Current Session (new): - Shows only when backup is active (preparing/running/paused) - Progress bar, file count, live upload speed, ETA, current filename - Badge colors: blue for uploading, orange for paused/scanning Last Successful Backup (new): - Trust signal always visible — shows date + total photos safe - Empty state if no clean backup yet Errors section: - "Retry Failed" button in header (disabled while active or NAS unreachable) - Error rows show count + uploaded count as context - Error detail sheet: Retry Failed Uploads button disabled with reason when NAS is down Sync Activity: - Clear button opens confirmationDialog: Clear Completed Logs / Clear Errors / Clear Everything - Labels: "Backups completed" / "New uploads" / "Already safe" / "Errors" - ConnectionStore: clearCompletedHistory() and clearErrorHistory() added History rows: - "skipped" replaced with human-readable text: "X already backed up", "X new · Y already safe", "X photos backed up" Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -3,16 +3,22 @@ import SwiftUI
|
|||||||
struct AppMenuView: View {
|
struct AppMenuView: View {
|
||||||
@EnvironmentObject var store: ConnectionStore
|
@EnvironmentObject var store: ConnectionStore
|
||||||
@EnvironmentObject var lanMonitor: LANMonitor
|
@EnvironmentObject var lanMonitor: LANMonitor
|
||||||
|
@EnvironmentObject var engine: BackupEngine
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var showClearConfirm = false
|
@State private var showClearSheet = false
|
||||||
@State private var selectedErrorEntry: BackupHistoryEntry?
|
@State private var selectedErrorEntry: BackupHistoryEntry?
|
||||||
|
|
||||||
private var errorEntries: [BackupHistoryEntry] {
|
private var errorEntries: [BackupHistoryEntry] {
|
||||||
store.historyEntries.filter { $0.failedCount > 0 }
|
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 {
|
private var wifiSubtitle: String {
|
||||||
if lanMonitor.isOnCellular { return "Cellular only" }
|
if lanMonitor.isOnCellular { return "Cellular only" }
|
||||||
if lanMonitor.isOnNetwork { return lanMonitor.currentSSID ?? "Connected" }
|
if lanMonitor.isOnNetwork { return lanMonitor.currentSSID ?? "Connected" }
|
||||||
@@ -26,12 +32,13 @@ struct AppMenuView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var wifiBadgeColor: Color {
|
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 }
|
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 {
|
private var nasBadgeLabel: String {
|
||||||
switch lanMonitor.nasReachable {
|
switch lanMonitor.nasReachable {
|
||||||
case true: return "Connected"
|
case true: return "Connected"
|
||||||
@@ -43,11 +50,41 @@ struct AppMenuView: View {
|
|||||||
private var nasBadgeColor: Color {
|
private var nasBadgeColor: Color {
|
||||||
switch lanMonitor.nasReachable {
|
switch lanMonitor.nasReachable {
|
||||||
case true: return AppTheme.positive
|
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
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -56,11 +93,18 @@ struct AppMenuView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppTheme.sectionGap) {
|
VStack(spacing: AppTheme.sectionGap) {
|
||||||
connectionStatusSection
|
connectionStatusSection
|
||||||
syncActivitySection
|
|
||||||
historySection
|
if engine.job.status.isActive {
|
||||||
|
currentSessionSection
|
||||||
|
}
|
||||||
|
|
||||||
if !errorEntries.isEmpty {
|
if !errorEntries.isEmpty {
|
||||||
errorsSection
|
errorsSection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastBackupSection
|
||||||
|
syncActivitySection
|
||||||
|
historySection
|
||||||
aboutSection
|
aboutSection
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
@@ -77,15 +121,13 @@ struct AppMenuView: View {
|
|||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Clear History", isPresented: $showClearConfirm) {
|
.confirmationDialog("Clear Activity", isPresented: $showClearSheet, titleVisibility: .visible) {
|
||||||
Button("Clear", role: .destructive) { store.clearHistory() }
|
Button("Clear Completed Logs") { store.clearCompletedHistory() }
|
||||||
Button("Cancel", role: .cancel) {}
|
if !errorEntries.isEmpty {
|
||||||
} message: {
|
Button("Clear Errors") { store.clearErrorHistory() }
|
||||||
if errorEntries.isEmpty {
|
Button("Clear Everything", role: .destructive) { store.clearHistory() }
|
||||||
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").")
|
|
||||||
}
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedErrorEntry) { entry in
|
.sheet(item: $selectedErrorEntry) { entry in
|
||||||
errorDetailSheet(entry: entry)
|
errorDetailSheet(entry: entry)
|
||||||
@@ -100,7 +142,7 @@ struct AppMenuView: View {
|
|||||||
menuSectionLabel("CONNECTION STATUS")
|
menuSectionLabel("CONNECTION STATUS")
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// NAS row
|
// NAS / destination row
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
iconBadge("server.rack", color: AppTheme.inkSecondary)
|
iconBadge("server.rack", color: AppTheme.inkSecondary)
|
||||||
|
|
||||||
@@ -109,9 +151,16 @@ struct AppMenuView: View {
|
|||||||
.font(AppTheme.body())
|
.font(AppTheme.body())
|
||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
if let conn = store.savedConnection {
|
if let conn = store.savedConnection {
|
||||||
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
|
Text("\(conn.nasProtocol.rawValue.uppercased()) · \(conn.host)")
|
||||||
.font(AppTheme.micro())
|
.font(AppTheme.micro())
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
.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
|
menuHairline
|
||||||
|
|
||||||
// Wi-Fi row — title is always "Wi-Fi"; subtitle shows SSID or connection type
|
// Wi-Fi row
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
iconBadge("wifi", color: AppTheme.inkSecondary)
|
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
|
// MARK: — Sync Activity
|
||||||
|
|
||||||
private var syncActivitySection: some View {
|
private var syncActivitySection: some View {
|
||||||
@@ -173,26 +398,31 @@ struct AppMenuView: View {
|
|||||||
menuSectionLabel("SYNC ACTIVITY")
|
menuSectionLabel("SYNC ACTIVITY")
|
||||||
Spacer()
|
Spacer()
|
||||||
if !store.historyEntries.isEmpty {
|
if !store.historyEntries.isEmpty {
|
||||||
Button("Clear") { showClearConfirm = true }
|
Button {
|
||||||
.font(.system(size: 12, weight: .medium))
|
showClearSheet = true
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
} label: {
|
||||||
|
Text("Clear ▾")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
let total = store.historyEntries.count
|
let total = store.historyEntries.count
|
||||||
let uploaded = store.historyEntries.reduce(0) { $0 + $1.uploadedCount }
|
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 }
|
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
|
menuHairline
|
||||||
|
activityStat(icon: "arrow.up.doc", label: "New uploads", value: "\(uploaded)")
|
||||||
activityStat(icon: "arrow.up.doc", label: "Files uploaded", value: "\(uploaded)")
|
menuHairline
|
||||||
|
activityStat(icon: "checkmark", label: "Already safe", value: "\(safe)")
|
||||||
|
|
||||||
if errors > 0 {
|
if errors > 0 {
|
||||||
menuHairline
|
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)
|
.background(AppTheme.surfaceRaised)
|
||||||
@@ -240,12 +470,10 @@ struct AppMenuView: View {
|
|||||||
.frame(width: 5, height: 5)
|
.frame(width: 5, height: 5)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
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())
|
.font(AppTheme.caption())
|
||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
Text("\(entry.uploadedCount) uploaded · \(entry.skippedCount) skipped")
|
historySubtitle(entry)
|
||||||
.font(AppTheme.micro())
|
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -266,50 +494,24 @@ struct AppMenuView: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: — Errors
|
@ViewBuilder
|
||||||
|
private func historySubtitle(_ entry: BackupHistoryEntry) -> some View {
|
||||||
private var errorsSection: some View {
|
if entry.failedCount > 0 {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
Text("\(entry.uploadedCount) uploaded · \(entry.failedCount) failed")
|
||||||
menuSectionLabel("ERRORS")
|
.font(AppTheme.micro())
|
||||||
|
.foregroundStyle(AppTheme.destructive.opacity(0.8))
|
||||||
VStack(spacing: 0) {
|
} else if entry.uploadedCount == 0 && entry.skippedCount > 0 {
|
||||||
ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in
|
Text("\(entry.skippedCount) already backed up")
|
||||||
Button {
|
.font(AppTheme.micro())
|
||||||
selectedErrorEntry = entry
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
} label: {
|
} else if entry.skippedCount > 0 {
|
||||||
HStack(spacing: 10) {
|
Text("\(entry.uploadedCount) new · \(entry.skippedCount) already safe")
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
.font(AppTheme.micro())
|
||||||
.font(.system(size: 12, weight: .regular))
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
.foregroundStyle(AppTheme.destructive)
|
} else {
|
||||||
|
Text("\(entry.uploadedCount) photo\(entry.uploadedCount == 1 ? "" : "s") backed up")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.font(AppTheme.micro())
|
||||||
Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +524,7 @@ struct AppMenuView: View {
|
|||||||
AppTheme.background.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppTheme.sectionGap) {
|
VStack(spacing: AppTheme.sectionGap) {
|
||||||
|
// Summary card
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
detailRow(label: "Date", value: entry.date.formatted(.dateTime.month(.wide).day().year().hour().minute()))
|
detailRow(label: "Date", value: entry.date.formatted(.dateTime.month(.wide).day().year().hour().minute()))
|
||||||
menuHairline
|
menuHairline
|
||||||
@@ -329,7 +532,7 @@ struct AppMenuView: View {
|
|||||||
menuHairline
|
menuHairline
|
||||||
detailRow(label: "Uploaded", value: "\(entry.uploadedCount) file\(entry.uploadedCount == 1 ? "" : "s")")
|
detailRow(label: "Uploaded", value: "\(entry.uploadedCount) file\(entry.uploadedCount == 1 ? "" : "s")")
|
||||||
menuHairline
|
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
|
menuHairline
|
||||||
detailRow(label: "Duration", value: durationString(entry.durationSeconds))
|
detailRow(label: "Duration", value: durationString(entry.durationSeconds))
|
||||||
menuHairline
|
menuHairline
|
||||||
@@ -341,18 +544,47 @@ struct AppMenuView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
|
|
||||||
|
// Suggested fix
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
menuSectionLabel("SUGGESTED FIX")
|
menuSectionLabel("SUGGESTED FIX")
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
fixRow(icon: "network", text: "Ensure the device is on the same network as the NAS.")
|
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: "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)
|
.padding(AppTheme.cardPad)
|
||||||
.background(AppTheme.surfaceRaised)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.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(.horizontal, AppTheme.hPad)
|
||||||
.padding(.top, 8)
|
.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
|
// MARK: — About
|
||||||
|
|
||||||
private var aboutSection: some View {
|
private var aboutSection: some View {
|
||||||
@@ -415,11 +611,6 @@ struct AppMenuView: View {
|
|||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image("ugreen_logo")
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 28, height: 28)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
|
||||||
Text("kisani.")
|
Text("kisani.")
|
||||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||||
.foregroundStyle(AppTheme.ink)
|
.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
|
// MARK: — Helpers
|
||||||
|
|
||||||
private func menuSectionLabel(_ text: String) -> some View {
|
private func menuSectionLabel(_ text: String) -> some View {
|
||||||
@@ -536,4 +735,55 @@ struct AppMenuView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ final class ConnectionStore: ObservableObject {
|
|||||||
UserDefaults.standard.removeObject(forKey: "historyEntries")
|
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.
|
/// Locks the screen — keeps the saved connection but requires Face ID again.
|
||||||
func lock() {
|
func lock() {
|
||||||
isSessionActive = false
|
isSessionActive = false
|
||||||
|
|||||||
Reference in New Issue
Block a user