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:
Robin Kutesa
2026-05-17 11:17:13 +03:00
parent 6cad8d9d6c
commit 59bdad803f
32 changed files with 983 additions and 227 deletions

View File

@@ -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))
}
}
}