327 lines
13 KiB
Swift
327 lines
13 KiB
Swift
|
|
import SwiftUI
|
||
|
|
|
||
|
|
struct AppMenuView: View {
|
||
|
|
@EnvironmentObject var store: ConnectionStore
|
||
|
|
@EnvironmentObject var lanMonitor: LANMonitor
|
||
|
|
@Environment(\.dismiss) private var dismiss
|
||
|
|
|
||
|
|
private var errorEntries: [BackupHistoryEntry] {
|
||
|
|
store.historyEntries.filter { $0.failedCount > 0 }
|
||
|
|
}
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
NavigationStack {
|
||
|
|
ZStack {
|
||
|
|
AppTheme.background.ignoresSafeArea()
|
||
|
|
|
||
|
|
ScrollView {
|
||
|
|
VStack(spacing: AppTheme.sectionGap) {
|
||
|
|
connectionStatusSection
|
||
|
|
syncActivitySection
|
||
|
|
historySection
|
||
|
|
if !errorEntries.isEmpty {
|
||
|
|
errorsSection
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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("externaldrive", 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: lanMonitor.nasReachable == true ? "Reachable" : lanMonitor.nasReachable == false ? "Offline" : "Unknown",
|
||
|
|
color: lanMonitor.nasReachable == true ? AppTheme.positive : lanMonitor.nasReachable == false ? AppTheme.destructive : AppTheme.inkQuaternary
|
||
|
|
)
|
||
|
|
}
|
||
|
|
.padding(.horizontal, AppTheme.cardPad)
|
||
|
|
.padding(.vertical, 14)
|
||
|
|
|
||
|
|
menuHairline
|
||
|
|
|
||
|
|
// Network row
|
||
|
|
HStack(spacing: 12) {
|
||
|
|
iconBadge("wifi", color: AppTheme.inkSecondary)
|
||
|
|
|
||
|
|
VStack(alignment: .leading, spacing: 2) {
|
||
|
|
Text(lanMonitor.currentSSID ?? "No Wi-Fi")
|
||
|
|
.font(AppTheme.body())
|
||
|
|
.foregroundStyle(AppTheme.ink)
|
||
|
|
Text(lanMonitor.isOnCellular ? "Cellular" : lanMonitor.isOnNetwork ? "Wi-Fi connected" : "Offline")
|
||
|
|
.font(AppTheme.micro())
|
||
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
||
|
|
}
|
||
|
|
|
||
|
|
Spacer()
|
||
|
|
|
||
|
|
statusBadge(
|
||
|
|
label: lanMonitor.isOnNetwork ? "Online" : "Offline",
|
||
|
|
color: lanMonitor.isOnNetwork ? AppTheme.positive : AppTheme.inkQuaternary
|
||
|
|
)
|
||
|
|
}
|
||
|
|
.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) {
|
||
|
|
menuSectionLabel("SYNC ACTIVITY")
|
||
|
|
|
||
|
|
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
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
.padding(.horizontal, AppTheme.cardPad)
|
||
|
|
.padding(.vertical, 12)
|
||
|
|
|
||
|
|
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: — 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)
|
||
|
|
}
|
||
|
|
}
|