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>
790 lines
32 KiB
Swift
790 lines
32 KiB
Swift
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 showClearSheet = false
|
|
@State private var selectedErrorEntry: BackupHistoryEntry?
|
|
|
|
private var errorEntries: [BackupHistoryEntry] {
|
|
store.historyEntries.filter { $0.failedCount > 0 }
|
|
}
|
|
|
|
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" }
|
|
return "Not connected"
|
|
}
|
|
|
|
private var wifiBadgeLabel: String {
|
|
if lanMonitor.isOnCellular { return "Cellular" }
|
|
if lanMonitor.isOnNetwork { return "Connected" }
|
|
return "Offline"
|
|
}
|
|
|
|
private var wifiBadgeColor: Color {
|
|
if lanMonitor.isOnCellular { return .orange }
|
|
if lanMonitor.isOnNetwork { return AppTheme.positive }
|
|
return .orange
|
|
}
|
|
|
|
// MARK: — NAS helpers
|
|
|
|
private var nasBadgeLabel: String {
|
|
switch lanMonitor.nasReachable {
|
|
case true: return "Connected"
|
|
case false: return "Offline"
|
|
default: return "Unknown"
|
|
}
|
|
}
|
|
|
|
private var nasBadgeColor: Color {
|
|
switch lanMonitor.nasReachable {
|
|
case true: return AppTheme.positive
|
|
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 {
|
|
AppTheme.background.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: AppTheme.sectionGap) {
|
|
connectionStatusSection
|
|
|
|
if engine.job.status.isActive {
|
|
currentSessionSection
|
|
}
|
|
|
|
if !errorEntries.isEmpty {
|
|
errorsSection
|
|
}
|
|
|
|
lastBackupSection
|
|
syncActivitySection
|
|
historySection
|
|
aboutSection
|
|
}
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 48)
|
|
}
|
|
}
|
|
.navigationTitle("Activity")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") { dismiss() }
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AppTheme.ink)
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — Connection Status
|
|
|
|
private var connectionStatusSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
menuSectionLabel("CONNECTION STATUS")
|
|
|
|
VStack(spacing: 0) {
|
|
// NAS / destination row
|
|
HStack(spacing: 12) {
|
|
iconBadge("server.rack", color: AppTheme.inkSecondary)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(store.savedConnection?.displayName ?? "No NAS connected")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
if let conn = store.savedConnection {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
statusBadge(label: nasBadgeLabel, color: nasBadgeColor)
|
|
}
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 14)
|
|
|
|
menuHairline
|
|
|
|
// Wi-Fi row
|
|
HStack(spacing: 12) {
|
|
iconBadge("wifi", color: AppTheme.inkSecondary)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Wi-Fi")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text(wifiSubtitle)
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
statusBadge(label: wifiBadgeLabel, color: wifiBadgeColor)
|
|
}
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 14)
|
|
|
|
if lanMonitor.isTailscaleActive {
|
|
menuHairline
|
|
|
|
HStack(spacing: 12) {
|
|
iconBadge("network.badge.shield.half.filled", color: AppTheme.interactive)
|
|
Text("Tailscale active")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Spacer()
|
|
statusBadge(label: "VPN", color: AppTheme.interactive)
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
menuSectionLabel("SYNC ACTIVITY")
|
|
Spacer()
|
|
if !store.historyEntries.isEmpty {
|
|
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: "Backups completed", value: "\(total)")
|
|
menuHairline
|
|
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: "Errors", value: "\(errors)", valueColor: AppTheme.destructive)
|
|
}
|
|
}
|
|
.background(AppTheme.surfaceRaised)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: — History
|
|
|
|
private var historySection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
menuSectionLabel("HISTORY")
|
|
|
|
if store.historyEntries.isEmpty {
|
|
emptyCard(icon: "clock.arrow.circlepath", text: "No backups yet")
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(store.historyEntries.prefix(5).enumerated()), id: \.element.id) { idx, entry in
|
|
compactHistoryRow(entry)
|
|
if idx < min(store.historyEntries.count, 5) - 1 {
|
|
menuHairline
|
|
}
|
|
}
|
|
if store.historyEntries.count > 5 {
|
|
menuHairline
|
|
Text("+ \(store.historyEntries.count - 5) more")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
.background(AppTheme.surfaceRaised)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func compactHistoryRow(_ entry: BackupHistoryEntry) -> some View {
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(entry.failedCount > 0 ? AppTheme.destructive : AppTheme.positive)
|
|
.frame(width: 5, height: 5)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(entry.date.formatted(.dateTime.month(.abbreviated).day().hour().minute()))
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.ink)
|
|
historySubtitle(entry)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if entry.triggeredByLAN {
|
|
Text("AUTO")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 2)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
|
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|
|
|
|
// MARK: — Error Detail Sheet
|
|
|
|
@ViewBuilder
|
|
private func errorDetailSheet(entry: BackupHistoryEntry) -> some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
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
|
|
detailRow(label: "Failed", value: "\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s")", valueColor: AppTheme.destructive)
|
|
menuHairline
|
|
detailRow(label: "Uploaded", value: "\(entry.uploadedCount) file\(entry.uploadedCount == 1 ? "" : "s")")
|
|
menuHairline
|
|
detailRow(label: "Already safe", value: "\(entry.skippedCount) file\(entry.skippedCount == 1 ? "" : "s")")
|
|
menuHairline
|
|
detailRow(label: "Duration", value: durationString(entry.durationSeconds))
|
|
menuHairline
|
|
detailRow(label: "NAS", value: entry.nasHost)
|
|
menuHairline
|
|
detailRow(label: "Triggered by", value: entry.triggeredByLAN ? "LAN (automatic)" : "Manual")
|
|
}
|
|
.background(AppTheme.surfaceRaised)
|
|
.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 — 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)
|
|
.padding(.bottom, 48)
|
|
}
|
|
}
|
|
.navigationTitle("Error Detail")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") { selectedErrorEntry = nil }
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AppTheme.ink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — About
|
|
|
|
private var aboutSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
menuSectionLabel("ABOUT")
|
|
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 12) {
|
|
Text("kisani.")
|
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.kerning(0.5)
|
|
Spacer()
|
|
Text("v1.0")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 14)
|
|
|
|
menuHairline
|
|
|
|
Link(destination: URL(string: "mailto:apps@provoc.ug")!) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "envelope")
|
|
.font(.system(size: 13, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.frame(width: 20)
|
|
Text("Support")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Spacer()
|
|
Text("apps@provoc.ug")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
Image(systemName: "arrow.up.right")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
Text(text)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
.kerning(0.6)
|
|
}
|
|
|
|
private var menuHairline: some View {
|
|
Rectangle()
|
|
.fill(AppTheme.separator)
|
|
.frame(height: 0.5)
|
|
.padding(.leading, AppTheme.cardPad)
|
|
}
|
|
|
|
private func iconBadge(_ name: String, color: Color) -> some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(AppTheme.surfaceSunken)
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: name)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(color)
|
|
}
|
|
}
|
|
|
|
private func statusBadge(label: String, color: Color) -> some View {
|
|
HStack(spacing: 4) {
|
|
Circle().fill(color).frame(width: 5, height: 5)
|
|
Text(label)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(color)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(color.opacity(0.08))
|
|
.clipShape(Capsule(style: .continuous))
|
|
}
|
|
|
|
private func activityStat(icon: String, label: String, value: String, valueColor: Color = AppTheme.ink) -> some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 13, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.frame(width: 20)
|
|
Text(label)
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Spacer()
|
|
Text(value)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(valueColor)
|
|
.monospacedDigit()
|
|
}
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 13)
|
|
}
|
|
|
|
private func emptyCard(icon: String, text: String) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 13, weight: .light))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
Text(text)
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 16)
|
|
.background(AppTheme.surfaceRaised)
|
|
.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"
|
|
}
|
|
}
|