- Rename app to Kisani in all UI text and Face ID prompt - Replace flat 2-state RootView with 5-step flow: ConnectView → LoginView → FolderSetupView → PhotoPermissionView → MainTabView - Remove NavigationStack from LoginView (was causing nested-stack crash); drive navigation via ConnectionStore.isSessionActive - Add FolderSetupView and PhotoPermissionView onboarding screens with step indicator (2/3, 3/3), spring entrance animations, and UGreen connection chip - Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25) - Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light) - Add ConnectionStore.isSessionActive (non-persisted) and onboardingPhotoShown (UserDefaults-persisted) - Add os_log logging in LoginViewModel at auth start/success/failure - Remove dead Help button from ConnectView - UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions, PrivacyInfo.xcprivacy, unit test target and test stubs - AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color - AppearanceMode enum + segmented picker in SettingsView - BackupView: enlarged ring, removed options card and linear progress bar - HistoryView: duration and bytes formatting, improved typography hierarchy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
169 lines
5.9 KiB
Swift
169 lines
5.9 KiB
Swift
import SwiftUI
|
|
|
|
struct HistoryView: View {
|
|
@EnvironmentObject var store: ConnectionStore
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
AppTheme.background.ignoresSafeArea()
|
|
|
|
if store.historyEntries.isEmpty {
|
|
emptyState
|
|
} else {
|
|
List {
|
|
ForEach(store.historyEntries) { entry in
|
|
HistoryRowView(entry: entry)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets())
|
|
.listRowBackground(AppTheme.background)
|
|
}
|
|
.onDelete { store.removeHistoryEntries(at: $0) }
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
}
|
|
.navigationTitle("History")
|
|
.toolbar {
|
|
if !store.historyEntries.isEmpty {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Clear") { store.clearHistory() }
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.font(.system(size: 32, weight: .light))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
VStack(spacing: 5) {
|
|
Text("No backups yet")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
Text("Your backup history will appear here")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — Row
|
|
|
|
struct HistoryRowView: View {
|
|
let entry: BackupHistoryEntry
|
|
|
|
private var hasErrors: Bool { entry.failedCount > 0 }
|
|
private var statusColor: Color { hasErrors ? AppTheme.destructive : AppTheme.positive }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
// Status indicator
|
|
Circle()
|
|
.fill(statusColor)
|
|
.frame(width: 6, height: 6)
|
|
.padding(.top, 6)
|
|
|
|
// Content
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
// Date + badges
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(AppTheme.ink)
|
|
|
|
Text(entry.date, format: .dateTime.hour().minute())
|
|
.font(.system(size: 13, weight: .regular))
|
|
.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)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Stat line
|
|
statLine
|
|
|
|
// Secondary info: host · duration · size
|
|
HStack(spacing: 6) {
|
|
Text(entry.nasHost)
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
midDot
|
|
Text(formatDuration(entry.durationSeconds))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
if entry.totalBytes > 0 {
|
|
midDot
|
|
Text(formatBytes(entry.totalBytes))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
}
|
|
.font(.system(size: 11, weight: .regular))
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
|
|
Rectangle()
|
|
.fill(AppTheme.separator)
|
|
.frame(height: 0.5)
|
|
.padding(.leading, 36)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statLine: some View {
|
|
HStack(spacing: 6) {
|
|
Text("\(entry.uploadedCount) uploaded")
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
|
|
if entry.skippedCount > 0 {
|
|
midDot
|
|
Text("\(entry.skippedCount) skipped")
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
|
|
if hasErrors {
|
|
midDot
|
|
Text("\(entry.failedCount) errors")
|
|
.foregroundStyle(AppTheme.destructive)
|
|
}
|
|
}
|
|
.font(AppTheme.caption())
|
|
}
|
|
|
|
private var midDot: some View {
|
|
Circle()
|
|
.fill(AppTheme.inkQuaternary)
|
|
.frame(width: 3, height: 3)
|
|
}
|
|
|
|
private func formatDuration(_ s: Double) -> String {
|
|
let m = Int(s) / 60
|
|
let sec = Int(s) % 60
|
|
return m > 0 ? "\(m)m \(sec)s" : "\(sec)s"
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let gb = Double(bytes) / 1_073_741_824
|
|
if gb >= 1 { return String(format: "%.1f GB", gb) }
|
|
let mb = Double(bytes) / 1_048_576
|
|
if mb >= 1 { return String(format: "%.0f MB", mb) }
|
|
return String(format: "%.0f KB", Double(bytes) / 1024)
|
|
}
|
|
}
|