perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes: - NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json) Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download. - LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json) Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver. O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls). Fast path reconcile: When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration entirely and returns in microseconds. Falls through to full NAS path only when stale. AutoBackupCoordinator.onActive(): Changed force: true → force: false so the 30-second debounce prevents expensive rescans on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot. BackupQueueItem + queue tracking: BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress, speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView always has data to show even after restart. NotificationService: Centralised local notification sender replacing the inline UNMutableNotificationContent blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category identifiers so same-type notifications replace each other. SyncView redesign: - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text - No live NAS directory listing (was expensive NAS connection on every tab open) - Shows engine.queueItems grouped into Active / Failed / Completed sections - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed - Idle state shows archive count from statusService snapshot GalleryViewModel: NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest is nil (offline launch). Gallery shows NAS items even when NAS is unreachable. AppDelegate: Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -3,43 +3,175 @@ import SwiftUI
|
||||
struct SyncView: View {
|
||||
@EnvironmentObject var store: ConnectionStore
|
||||
@EnvironmentObject var lanMonitor: LANMonitor
|
||||
|
||||
@State private var files: [NASItem] = []
|
||||
@State private var isLoading = false
|
||||
@State private var error: String? = nil
|
||||
@State private var appeared = false
|
||||
@EnvironmentObject var engine: BackupEngine
|
||||
@EnvironmentObject var statusService: BackupStatusService
|
||||
|
||||
private var connection: NASConnection? { store.savedConnection }
|
||||
private var queueItems: [BackupQueueItem] { engine.queueItems }
|
||||
|
||||
private var uploadingItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploading } }
|
||||
private var queuedItems: [BackupQueueItem] { queueItems.filter { $0.status == .queued } }
|
||||
private var completedItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploaded || $0.status == .skipped } }
|
||||
private var failedItems: [BackupQueueItem] { queueItems.filter { $0.status == .failed } }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
if connection == nil {
|
||||
noConnectionState
|
||||
} else if isLoading && files.isEmpty {
|
||||
loadingState
|
||||
} else if let err = error {
|
||||
errorState(err)
|
||||
} else if files.isEmpty && !isLoading {
|
||||
emptyState
|
||||
} else {
|
||||
fileList
|
||||
contentList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sync")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
nasStatusChip
|
||||
}
|
||||
|
||||
// MARK: — Main content
|
||||
|
||||
private var contentList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: []) {
|
||||
nasHeader
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
|
||||
if queueItems.isEmpty {
|
||||
idleState
|
||||
.padding(.top, 48)
|
||||
} else {
|
||||
queueContent
|
||||
}
|
||||
|
||||
freeUpSpaceSection
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.top, 32)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
.task { await loadFiles() }
|
||||
.refreshable { await loadFiles() }
|
||||
.refreshable {
|
||||
// Pull-to-refresh: show latest queue from engine (already live)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — NAS header (no pill, no border)
|
||||
|
||||
private var nasHeader: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
if let conn = connection {
|
||||
Text(conn.host)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(conn.remotePath)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.lineLimit(1)
|
||||
Text(conn.nasProtocol == .smb ? "SMB" : "SFTP")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(nasColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
|
||||
Text(nasLabel)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(nasColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var nasColor: Color {
|
||||
switch lanMonitor.nasReachable {
|
||||
case true: AppTheme.positive
|
||||
case false: AppTheme.destructive.opacity(0.8)
|
||||
case nil: AppTheme.inkTertiary
|
||||
}
|
||||
}
|
||||
|
||||
private var nasLabel: String {
|
||||
switch lanMonitor.nasReachable {
|
||||
case true: "Online"
|
||||
case false: "Offline"
|
||||
case nil: "Checking…"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Queue content
|
||||
|
||||
@ViewBuilder
|
||||
private var queueContent: some View {
|
||||
// Active / uploading
|
||||
if !uploadingItems.isEmpty || !queuedItems.isEmpty {
|
||||
sectionHeader("ACTIVE", count: uploadingItems.count + queuedItems.count)
|
||||
ForEach(uploadingItems + queuedItems) { item in
|
||||
QueueFileRow(item: item)
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
}
|
||||
|
||||
// Failed
|
||||
if !failedItems.isEmpty {
|
||||
sectionHeader("FAILED", count: failedItems.count)
|
||||
ForEach(failedItems) { item in
|
||||
QueueFileRow(item: item)
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
}
|
||||
|
||||
// Completed
|
||||
if !completedItems.isEmpty {
|
||||
sectionHeader("COMPLETED", count: completedItems.count)
|
||||
ForEach(completedItems.prefix(30)) { item in
|
||||
QueueFileRow(item: item)
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String, count: Int) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.kerning(0.8)
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
// MARK: — States
|
||||
|
||||
private var idleState: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: statusService.snapshot.alreadySafe > 0 ? "checkmark.circle" : "arrow.up.doc")
|
||||
.font(.system(size: 32, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
VStack(spacing: 5) {
|
||||
Text(statusService.snapshot.alreadySafe > 0 ? "All caught up" : "No backup run yet")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
if statusService.snapshot.nasArchiveTotal > 0 {
|
||||
Text("\(statusService.snapshot.nasArchiveTotal) files on NAS")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var noConnectionState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "externaldrive.badge.xmark")
|
||||
@@ -49,184 +181,53 @@ struct SyncView: View {
|
||||
Text("No NAS connected")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Text("Connect to a NAS to see synced files")
|
||||
Text("Connect to a NAS to see sync activity")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingState: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().tint(AppTheme.inkTertiary)
|
||||
Text("Loading files…")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorState(_ message: String) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
ZStack {
|
||||
Circle().fill(AppTheme.surfaceSunken).frame(width: 64, height: 64)
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
.font(.system(size: 24, weight: .light))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text("Couldn't load files")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(message)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
Button(action: { Task { await loadFiles() } }) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "arrow.clockwise").font(.system(size: 12, weight: .medium))
|
||||
Text("Retry").font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 32, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
VStack(spacing: 5) {
|
||||
Text("Nothing backed up yet")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
if let conn = connection {
|
||||
Text(conn.remotePath)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — File list
|
||||
|
||||
private var fileList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
// Header strip
|
||||
headerStrip
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
|
||||
ForEach(Array(files.enumerated()), id: \.element.id) { idx, file in
|
||||
SyncFileRow(item: file)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 6)
|
||||
.animation(
|
||||
.spring(response: 0.38, dampingFraction: 0.82)
|
||||
.delay(Double(min(idx, 12)) * 0.03),
|
||||
value: appeared
|
||||
)
|
||||
|
||||
if idx < files.count - 1 {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
|
||||
// Free Up Space section
|
||||
freeUpSpaceSection
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.top, 28)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
|
||||
private var headerStrip: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("\(files.count) files backed up")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
if let conn = connection {
|
||||
Text(conn.remotePath)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(reachableColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
|
||||
Text(reachableLabel)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(reachableColor)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Free Up Space
|
||||
|
||||
private var freeUpSpaceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel("FREE UP SPACE")
|
||||
Text("FREE UP SPACE")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.kerning(0.8)
|
||||
.padding(.horizontal, 2)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Explanation
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "iphone.and.arrow.forward")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Delete local copies after backup")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("Photos and videos already on your NAS")
|
||||
Text("Files already on your NAS")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
.padding(AppTheme.cardPad)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, AppTheme.cardPad)
|
||||
Divider().padding(.leading, AppTheme.cardPad)
|
||||
|
||||
// Retention picker
|
||||
HStack {
|
||||
Text("Delete after")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Spacer()
|
||||
Menu {
|
||||
Button("Never (keep all)") { store.localRetentionDays = 0 }
|
||||
Button("After 60 days") { store.localRetentionDays = 60 }
|
||||
Button("After 90 days") { store.localRetentionDays = 90 }
|
||||
Button("After 1 year") { store.localRetentionDays = 365 }
|
||||
Button("Never") { store.localRetentionDays = 0 }
|
||||
Button("60 days") { store.localRetentionDays = 60 }
|
||||
Button("90 days") { store.localRetentionDays = 90 }
|
||||
Button("1 year") { store.localRetentionDays = 365 }
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(retentionLabel)
|
||||
@@ -238,172 +239,162 @@ struct SyncView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if store.localRetentionDays > 0 {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, AppTheme.cardPad)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
Text("Files will be removed from this device \(retentionLabel.lowercased()) they were successfully backed up. They remain on your NAS.")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.padding(AppTheme.cardPad)
|
||||
}
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.localRetentionDays > 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var retentionLabel: String {
|
||||
switch store.localRetentionDays {
|
||||
case 0: return "Never"
|
||||
case 60: return "60 days"
|
||||
case 90: return "90 days"
|
||||
case 365: return "1 year"
|
||||
default: return "\(store.localRetentionDays) days"
|
||||
case 0: "Never"
|
||||
case 60: "60 days"
|
||||
case 90: "90 days"
|
||||
case 365: "1 year"
|
||||
default: "\(store.localRetentionDays) days"
|
||||
}
|
||||
}
|
||||
|
||||
private var reachableColor: Color {
|
||||
switch lanMonitor.nasReachable {
|
||||
case true: return AppTheme.positive
|
||||
case false: return AppTheme.destructive.opacity(0.7)
|
||||
case nil: return AppTheme.inkQuaternary
|
||||
}
|
||||
}
|
||||
|
||||
private var reachableLabel: String {
|
||||
switch lanMonitor.nasReachable {
|
||||
case true: return "Online"
|
||||
case false: return "Offline"
|
||||
case nil: return "Checking…"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Toolbar chip
|
||||
|
||||
private var nasStatusChip: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary)
|
||||
.frame(width: 5, height: 5)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
|
||||
Text(connection?.host ?? "NAS")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Data
|
||||
|
||||
private func loadFiles() async {
|
||||
guard let conn = connection else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
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)
|
||||
files = try await s.listDirectory(at: conn.remotePath)
|
||||
.filter { !$0.isDirectory }
|
||||
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
|
||||
s.disconnect()
|
||||
} catch let e as BackupError {
|
||||
self.error = e.errorDescription
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — File Row
|
||||
// MARK: — Per-file row
|
||||
|
||||
struct SyncFileRow: View {
|
||||
let item: NASItem
|
||||
struct QueueFileRow: View {
|
||||
let item: BackupQueueItem
|
||||
|
||||
private var ext: String { (item.name as NSString).pathExtension.lowercased() }
|
||||
private var ext: String { (item.filename as NSString).pathExtension.lowercased() }
|
||||
|
||||
private var fileIcon: String {
|
||||
switch ext {
|
||||
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return "photo"
|
||||
case "mp4", "mov", "m4v", "avi": return "play.rectangle"
|
||||
case "raw", "dng", "cr2", "nef", "arw": return "camera.aperture"
|
||||
case "pdf": return "doc.richtext"
|
||||
default: return "doc"
|
||||
case "jpg","jpeg","png","heic","heif","gif","webp": "photo"
|
||||
case "mp4","mov","m4v","avi": "play.rectangle"
|
||||
case "raw","dng","cr2","nef","arw": "camera.aperture"
|
||||
case "pdf": "doc.richtext"
|
||||
default: "doc"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch ext {
|
||||
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return AppTheme.interactive
|
||||
case "mp4", "mov", "m4v", "avi": return AppTheme.destructive
|
||||
case "raw", "dng", "cr2", "nef", "arw": return AppTheme.inkSecondary
|
||||
default: return AppTheme.inkTertiary
|
||||
case "jpg","jpeg","png","heic","heif","gif","webp": AppTheme.interactive
|
||||
case "mp4","mov","m4v","avi": AppTheme.destructive
|
||||
case "raw","dng","cr2","nef","arw": AppTheme.inkSecondary
|
||||
default: AppTheme.inkTertiary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// File type icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(iconColor.opacity(0.1))
|
||||
.frame(width: 38, height: 38)
|
||||
Image(systemName: fileIcon)
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundStyle(iconColor)
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(iconColor.opacity(0.1))
|
||||
.frame(width: 38, height: 38)
|
||||
Image(systemName: fileIcon)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.filename)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.lineLimit(1)
|
||||
statusRow
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
statusBadge
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
// Name + meta
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.name)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if let date = item.modifiedDate {
|
||||
Text(date, format: .dateTime.month(.abbreviated).day().year())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
// Progress bar for uploading
|
||||
if item.status == .uploading && item.totalBytes > 0 {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.frame(height: 2)
|
||||
Rectangle()
|
||||
.fill(AppTheme.interactive)
|
||||
.frame(width: geo.size.width * CGFloat(item.progress), height: 2)
|
||||
.animation(.linear(duration: 0.1), value: item.progress)
|
||||
}
|
||||
if item.size > 0 {
|
||||
Text("·").foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(formatBytes(item.size))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
.frame(height: 2)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusRow: some View {
|
||||
switch item.status {
|
||||
case .uploading:
|
||||
if item.totalBytes > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Text("\(formatBytes(item.bytesUploaded)) / \(formatBytes(item.totalBytes))")
|
||||
if item.speedBytesPerSec > 0 {
|
||||
Text("·")
|
||||
Text("\(formatBytes(Int64(item.speedBytesPerSec)))/s")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
} else {
|
||||
Text("Uploading…")
|
||||
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Synced badge
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppTheme.positive.opacity(0.7))
|
||||
case .queued:
|
||||
Text("Queued")
|
||||
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
|
||||
case .uploaded, .skipped:
|
||||
if let date = item.completedAt {
|
||||
Text(date, format: .dateTime.month(.abbreviated).day().hour().minute())
|
||||
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
case .failed:
|
||||
Text(item.error?.message ?? "Upload failed")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(AppTheme.destructive.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBadge: some View {
|
||||
switch item.status {
|
||||
case .uploading:
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.tint(AppTheme.interactive)
|
||||
case .queued:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
case .uploaded:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(AppTheme.positive.opacity(0.8))
|
||||
case .skipped:
|
||||
Image(systemName: "arrow.forward.circle")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
case .failed:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(AppTheme.destructive.opacity(0.8))
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
|
||||
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)
|
||||
if mb < 1 { return "\(max(0, bytes / 1024)) KB" }
|
||||
if mb < 1000 { return String(format: "%.1f MB", mb) }
|
||||
return String(format: "%.1f GB", mb / 1024)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user