feat: Kisani rebrand, step-based onboarding flow, dark mode fixes

- 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>
This commit is contained in:
Robin Kutesa
2026-05-16 16:20:53 +03:00
parent 03d63e60de
commit ae38f6e417
38 changed files with 1649 additions and 451 deletions

View File

@@ -5,29 +5,22 @@ struct HistoryView: View {
var body: some View {
ZStack {
Color.white.ignoresSafeArea()
AppTheme.background.ignoresSafeArea()
if store.historyEntries.isEmpty {
VStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 36, weight: .light))
.foregroundStyle(AppTheme.inkQuaternary)
Text("No backup history yet")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
}
emptyState
} else {
List {
ForEach(store.historyEntries) { entry in
HistoryRowView(entry: entry)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.listRowBackground(AppTheme.background)
}
.onDelete { offsets in
store.removeHistoryEntries(at: offsets)
}
.onDelete { store.removeHistoryEntries(at: $0) }
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.navigationTitle("History")
@@ -36,32 +29,60 @@ struct HistoryView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Clear") { store.clearHistory() }
.font(AppTheme.caption())
.foregroundStyle(AppTheme.destructive)
.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(hasErrors ? AppTheme.destructive : AppTheme.positive)
.frame(width: 7, height: 7)
.padding(.top, 5)
.fill(statusColor)
.frame(width: 6, height: 6)
.padding(.top, 6)
// Content
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(entry.date, style: .date)
.font(.system(size: 15, weight: .medium))
// 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))
@@ -73,37 +94,25 @@ struct HistoryRowView: View {
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
)
}
Spacer()
Text(entry.date, style: .time)
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
}
HStack(spacing: 8) {
Text("\(entry.uploadedCount) uploaded")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
// Stat line
statLine
if entry.skippedCount > 0 {
dot
Text("\(entry.skippedCount) skipped")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
if entry.failedCount > 0 {
dot
Text("\(entry.failedCount) errors")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.destructive)
// 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)
}
}
Text(entry.nasHost)
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkQuaternary)
.font(.system(size: 11, weight: .regular))
}
}
.padding(.horizontal, 16)
@@ -112,13 +121,48 @@ struct HistoryRowView: View {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 37)
.padding(.leading, 36)
}
}
private var dot: some View {
@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)
}
}