feat: Kisani UI overhaul — brand identity, gallery, visual polish
- Rename app to Kisani (CFBundleDisplayName, xcodeproj → Kisani.xcodeproj) - BackupView: kisani. wordmark + SwiftUI-drawn server logo mark (no asset, adaptive ink/bg), 206pt progress ring with active-state glow, 4-page swipeable ring (progress/pending/failed/uploaded), 4-metric stats strip, friendly NAS card (Home Server label), hamburger → AppMenuView sheet - GalleryView: new NAS + local photo grid with source picker chip, 3-col lazy grid, ThumbnailCache, sync-status dots (green = backed up) - MainTabView: Browse tab → Gallery tab; tab bar uses ultraThinMaterial blur background (systemGray3 icons, 9.5pt labels) - All app icon sizes regenerated from 1024pt source via sips - LANMonitor: nasReachable Bool?, checkNASReachability(host:port:) TCP ping - project.yml: photo library usage description updated to Kisani branding Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -8,92 +8,141 @@ struct BackupView: View {
|
||||
@StateObject private var vm = BackupViewModel()
|
||||
|
||||
@State private var showMenu = false
|
||||
@State private var showDestinationPicker = false
|
||||
@State private var pickerPath: String = "/"
|
||||
@State private var ringPage = 0
|
||||
@State private var nasFileCount: Int? = nil
|
||||
|
||||
private let ringPageCount = 4
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
// ─── Menu — aligned with kisani. wordmark ──────────────────
|
||||
Button { showMenu = true } label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.accessibilityLabel("Activity menu")
|
||||
.padding(.top, 4)
|
||||
.padding(.trailing, AppTheme.hPad - 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// ─── Ring hero ───────────────────────────────────────
|
||||
// ─── Brand ───────────────────────────────────────────
|
||||
VStack(spacing: 10) {
|
||||
Text("kisani.")
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.kerning(2)
|
||||
kisaniLogoMark
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 16)
|
||||
|
||||
// flex zone — brand ↔ ring (absorbs screen-size variance)
|
||||
Spacer(minLength: 24).frame(maxHeight: 48)
|
||||
|
||||
// ─── Ring ─────────────────────────────────────────────
|
||||
ringHero
|
||||
.padding(.bottom, 24)
|
||||
.padding(.bottom, 44)
|
||||
|
||||
// ─── Compact stats strip (always visible) ─────────────
|
||||
// ─── Stats ────────────────────────────────────────────
|
||||
statsStrip
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 12)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
||||
|
||||
Spacer(minLength: 0)
|
||||
// flex zone — stats ↔ cards
|
||||
Spacer(minLength: 20).frame(maxHeight: 48)
|
||||
|
||||
// ─── NAS status ───────────────────────────────────────
|
||||
// ─── Storage card ─────────────────────────────────────
|
||||
nasStatusRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// ─── Source card ──────────────────────────────────────
|
||||
destinationRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
|
||||
// flex zone — cards ↔ CTA
|
||||
Spacer(minLength: 20).frame(maxHeight: 32)
|
||||
|
||||
// ─── Photos access nudge ──────────────────────────────
|
||||
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
|
||||
photosAccessRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.bottom, 8)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
// ─── Action ───────────────────────────────────────────
|
||||
// ─── CTA ──────────────────────────────────────────────
|
||||
actionButton
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 32)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// bottom absorber — soaks up remaining height to anchor cards+CTA
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
logoChip
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showMenu = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
.accessibilityLabel("Activity menu")
|
||||
}
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.sheet(isPresented: $showMenu) {
|
||||
AppMenuView()
|
||||
.environmentObject(store)
|
||||
.environmentObject(lanMonitor)
|
||||
}
|
||||
.sheet(isPresented: $showDestinationPicker, onDismiss: {
|
||||
guard pickerPath != "/", var conn = store.savedConnection else { return }
|
||||
conn.remotePath = pickerPath
|
||||
store.savedConnection = conn
|
||||
}) {
|
||||
if let conn = store.savedConnection {
|
||||
BrowseView(connection: conn, selectedPath: $pickerPath, isPickerMode: true, initialPath: pickerPath)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
|
||||
.task { vm.loadPhotoCount(filter: store.backupFilter) }
|
||||
.task {
|
||||
vm.loadPhotoCount(filter: store.backupFilter)
|
||||
await loadNASCount()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Logo chip
|
||||
// MARK: — Logo mark (drawn in code — no asset dependency)
|
||||
|
||||
private var logoChip: some View {
|
||||
HStack(spacing: 6) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(AppTheme.ink)
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkInverse)
|
||||
}
|
||||
Text("kisani.")
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.kerning(0.5)
|
||||
private var kisaniLogoMark: some View {
|
||||
VStack(spacing: 0) {
|
||||
driveRow
|
||||
AppTheme.ink.frame(height: 1.5)
|
||||
driveRow
|
||||
AppTheme.ink.frame(height: 1.5)
|
||||
driveRow
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 11, style: .continuous)
|
||||
.strokeBorder(AppTheme.ink, lineWidth: 2.5)
|
||||
)
|
||||
.frame(width: 66, height: 66)
|
||||
.shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 2)
|
||||
}
|
||||
|
||||
private var driveRow: some View {
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(AppTheme.ink)
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.trailing, 7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 18)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
}
|
||||
|
||||
// MARK: — Ring hero
|
||||
@@ -102,11 +151,16 @@ struct BackupView: View {
|
||||
ZStack {
|
||||
ProgressRing(
|
||||
progress: engine.job.progress,
|
||||
lineWidth: 7,
|
||||
size: 220,
|
||||
lineWidth: 8,
|
||||
size: 206,
|
||||
color: ringColor,
|
||||
backgroundColor: AppTheme.inkQuaternary.opacity(0.4)
|
||||
backgroundColor: AppTheme.inkQuaternary.opacity(0.6)
|
||||
)
|
||||
.shadow(
|
||||
color: ringColor.opacity(engine.job.status.isActive ? 0.16 : 0),
|
||||
radius: 22, x: 0, y: 0
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.5), value: engine.job.status.isActive)
|
||||
.accessibilityLabel("Backup progress")
|
||||
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
|
||||
|
||||
@@ -194,12 +248,6 @@ struct BackupView: View {
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Text(engine.job.currentFileName)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 160)
|
||||
}
|
||||
case .preparing:
|
||||
Text("Preparing…")
|
||||
@@ -243,7 +291,7 @@ struct BackupView: View {
|
||||
case .failed: return AppTheme.destructive
|
||||
case .running, .paused,
|
||||
.preparing: return Color(red: 1.0, green: 0.58, blue: 0.0)
|
||||
default: return AppTheme.inkQuaternary.opacity(0.6)
|
||||
default: return AppTheme.inkTertiary.opacity(0.35)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,43 +300,139 @@ struct BackupView: View {
|
||||
// MARK: — Compact stats strip
|
||||
|
||||
private var statsStrip: some View {
|
||||
VStack(spacing: 5) {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
compactStat("\(engine.job.uploadedFiles)", label: "up", color: AppTheme.positive)
|
||||
compactStat(
|
||||
nasFileCount.map { "\($0)" } ?? "–",
|
||||
label: "in NAS",
|
||||
color: AppTheme.interactive
|
||||
)
|
||||
thinDivider
|
||||
compactStat("\(engine.job.skippedFiles)", label: "skip", color: AppTheme.inkQuaternary)
|
||||
compactStat(
|
||||
"\(vm.totalPhotoCount)",
|
||||
label: "on iPhone",
|
||||
color: AppTheme.inkSecondary
|
||||
)
|
||||
thinDivider
|
||||
compactStat("\(engine.job.failedFiles)", label: "err",
|
||||
color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkQuaternary)
|
||||
compactStat(
|
||||
"\(engine.job.uploadedFiles)",
|
||||
label: "uploaded",
|
||||
color: AppTheme.positive
|
||||
)
|
||||
thinDivider
|
||||
compactStat("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))", label: "left", color: AppTheme.inkSecondary)
|
||||
compactStat(
|
||||
"\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))",
|
||||
label: "left",
|
||||
color: AppTheme.inkSecondary
|
||||
)
|
||||
}
|
||||
.frame(height: 56)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
|
||||
pageIndicator
|
||||
}
|
||||
}
|
||||
|
||||
private func compactStat(_ value: String, label: String, color: Color) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
VStack(spacing: 3) {
|
||||
Text(value)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.monospacedDigit()
|
||||
Text(label)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.font(.system(size: 9, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var thinDivider: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(width: 0.5)
|
||||
.padding(.vertical, 5)
|
||||
.fill(AppTheme.separator.opacity(0.7))
|
||||
.frame(width: 0.5, height: 22)
|
||||
}
|
||||
|
||||
private func loadNASCount() async {
|
||||
guard let conn = store.savedConnection else { return }
|
||||
do {
|
||||
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await s.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
let items = try await s.listDirectory(at: conn.remotePath)
|
||||
nasFileCount = items.filter { !$0.isDirectory }.count
|
||||
s.disconnect()
|
||||
} catch {
|
||||
nasFileCount = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Destination row
|
||||
|
||||
private var destinationRow: some View {
|
||||
Button(action: {
|
||||
pickerPath = store.savedConnection?.remotePath ?? "/"
|
||||
showDestinationPicker = true
|
||||
}) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "folder.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
|
||||
let path = store.savedConnection?.remotePath ?? "/"
|
||||
let folderName = path == "/" ? "Not set" : (path as NSString).lastPathComponent
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(folderName)
|
||||
.font(AppTheme.headline())
|
||||
.foregroundStyle(path == "/" ? AppTheme.inkTertiary : AppTheme.ink)
|
||||
Text("Backup folder")
|
||||
.font(AppTheme.micro(10))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.disabled(store.savedConnection == nil)
|
||||
}
|
||||
|
||||
private var nasStatusDotColor: Color {
|
||||
switch engine.job.status {
|
||||
case .running, .preparing, .paused:
|
||||
return Color(red: 1.0, green: 0.58, blue: 0.0)
|
||||
default:
|
||||
switch lanMonitor.nasReachable {
|
||||
case true: return AppTheme.positive
|
||||
case false: return AppTheme.destructive
|
||||
case nil: return AppTheme.inkQuaternary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var nasStatusLabel: String {
|
||||
switch engine.job.status {
|
||||
case .running: return "Backing up"
|
||||
case .preparing: return "Preparing"
|
||||
case .paused: return "Paused"
|
||||
default:
|
||||
return lanMonitor.nasReachable == false ? "Offline" : "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — NAS status row
|
||||
@@ -305,24 +449,17 @@ struct BackupView: View {
|
||||
}
|
||||
|
||||
if let conn = store.savedConnection {
|
||||
let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(conn.displayName)
|
||||
Text(friendlyName)
|
||||
.font(AppTheme.headline())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
HStack(spacing: 4) {
|
||||
Text(conn.nasProtocol.rawValue)
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
if let ssid = lanMonitor.currentSSID {
|
||||
Text("·")
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(ssid)
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
.font(AppTheme.micro(10))
|
||||
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
|
||||
.font(AppTheme.micro(10))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
} else {
|
||||
Text("No NAS configured")
|
||||
Text("No storage connected")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
@@ -332,16 +469,22 @@ struct BackupView: View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
|
||||
.fill(nasStatusDotColor)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(engine.job.status.isActive ? "Active" : "Ready")
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive)
|
||||
Text(nasStatusLabel)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
|
||||
.foregroundStyle(nasStatusDotColor)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
|
||||
}
|
||||
if let reachable = lanMonitor.nasReachable {
|
||||
Text(reachable ? "NAS reachable" : "NAS offline")
|
||||
if lanMonitor.nasReachable == false {
|
||||
Text("Unreachable")
|
||||
.font(.system(size: 9, weight: .regular))
|
||||
.foregroundStyle(reachable ? AppTheme.positive.opacity(0.7) : AppTheme.destructive.opacity(0.7))
|
||||
.foregroundStyle(AppTheme.destructive.opacity(0.7))
|
||||
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
|
||||
Text("Connected")
|
||||
.font(.system(size: 9, weight: .regular))
|
||||
.foregroundStyle(AppTheme.positive.opacity(0.7))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user