Files
Kisani/Features/Backup/AppMenuView.swift
Robin Kutesa a68cf0e5ec Fix NAS badge color and icon in AppMenuView
- NAS reachable → green "Connected" (TCP ping = usable, not a warning state)
- NAS offline → orange "Offline" (matches color rule: orange = not reachable)
- NAS icon changed from externaldrive to server.rack

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:01:42 +03:00

540 lines
22 KiB
Swift

import SwiftUI
struct AppMenuView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@Environment(\.dismiss) private var dismiss
@State private var showClearConfirm = false
@State private var selectedErrorEntry: BackupHistoryEntry?
private var errorEntries: [BackupHistoryEntry] {
store.historyEntries.filter { $0.failedCount > 0 }
}
// Wi-Fi state 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 Color(red: 1.0, green: 0.58, blue: 0.0) }
if lanMonitor.isOnNetwork { return AppTheme.positive }
return AppTheme.inkQuaternary
}
// NAS badge green when reachable, orange when offline, muted when unknown
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 Color(red: 1.0, green: 0.58, blue: 0.0)
default: return AppTheme.inkQuaternary
}
}
var body: some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: AppTheme.sectionGap) {
connectionStatusSection
syncActivitySection
historySection
if !errorEntries.isEmpty {
errorsSection
}
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)
}
}
.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").")
}
}
.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 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) · \(conn.host)")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
}
Spacer()
statusBadge(label: nasBadgeLabel, color: nasBadgeColor)
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 14)
menuHairline
// Wi-Fi row title is always "Wi-Fi"; subtitle shows SSID or connection type
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: Sync Activity
private var syncActivitySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
menuSectionLabel("SYNC ACTIVITY")
Spacer()
if !store.historyEntries.isEmpty {
Button("Clear") { showClearConfirm = true }
.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 errors = store.historyEntries.reduce(0) { $0 + $1.failedCount }
activityStat(icon: "clock.arrow.circlepath", label: "Total backups", value: "\(total)")
menuHairline
activityStat(icon: "arrow.up.doc", label: "Files uploaded", value: "\(uploaded)")
if errors > 0 {
menuHairline
activityStat(icon: "exclamationmark.triangle", label: "Total 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, format: .dateTime.month(.abbreviated).day().year().hour().minute())
.font(AppTheme.caption())
.foregroundStyle(AppTheme.ink)
Text("\(entry.uploadedCount) uploaded · \(entry.skippedCount) skipped")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
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)
}
// 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)
}
}
// MARK: Error Detail Sheet
@ViewBuilder
private func errorDetailSheet(entry: BackupHistoryEntry) -> some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: AppTheme.sectionGap) {
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: "Skipped", 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)
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.")
}
.padding(AppTheme.cardPad)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
.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)
}
}
}
}
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 {
VStack(alignment: .leading, spacing: 8) {
menuSectionLabel("ABOUT")
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)
.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: 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)
}
}