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:
Robin Kutesa
2026-05-17 12:12:26 +03:00
parent a68cf0e5ec
commit 1312ebb278
2 changed files with 378 additions and 118 deletions

View File

@@ -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"
}
} }

View File

@@ -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