Apply Emil Kowalski design across all screens

Near-monochrome palette (ink tokens), black primary buttons, spring
animations, 0.5pt hairline dividers, shadow cards, squircle corners.
Replaces all blue/coloured accents. Adds ButtonStyles component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-05-16 00:43:31 +03:00
parent 848c1da820
commit 04e7a77c1a
14 changed files with 889 additions and 625 deletions

View File

@@ -11,193 +11,213 @@ struct BackupView: View {
Color.white.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Ring + counts
VStack(spacing: AppTheme.sectionGap) {
ringSection
// NAS status
nasStatusRow
// Options card
nasRow
optionsCard
// Start / pause button
actionButton
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 40)
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 24)
.padding(.bottom, 48)
}
}
.task {
vm.loadPhotoCount(filter: store.backupFilter)
}
.task { vm.loadPhotoCount(filter: store.backupFilter) }
}
// MARK: Ring
// MARK: Ring
private var ringSection: some View {
VStack(spacing: 16) {
VStack(spacing: 20) {
ZStack {
ProgressRing(
progress: engine.job.progress,
lineWidth: 14,
size: 200,
color: ringColor
lineWidth: 8,
size: 180,
color: ringColor,
backgroundColor: Color(hex: "#EBEBEB")
)
VStack(spacing: 4) {
VStack(spacing: 3) {
Text(percentText)
.font(.system(size: 36, weight: .bold))
.foregroundColor(AppTheme.textPrimary)
.font(.system(size: 40, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
if case .running = engine.job.status {
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) / \(engine.job.totalFiles)")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
if let eta = engine.job.eta {
Text("~\(etaString(eta))")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
Text("~\(etaString(eta)) remaining")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
Text("\(vm.totalPhotoCount) photos")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
Text("0 backed up")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
}
}
}
// Completion summary
if case .completed = engine.job.status {
completionSummary
HStack(spacing: 0) {
statCell("\(engine.job.uploadedFiles)", label: "uploaded")
dividerLine
statCell("\(engine.job.skippedFiles)", label: "skipped")
dividerLine
statCell("\(engine.job.failedFiles)", label: "errors",
color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkTertiary)
}
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
// Progress detail row
// Progress detail
if case .running = engine.job.status {
progressDetailRow
VStack(spacing: 8) {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color(hex: "#EBEBEB"))
.frame(height: 3)
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(AppTheme.ink)
.frame(width: geo.size.width * engine.job.progress, height: 3)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
}
.frame(height: 3)
HStack {
Text(engine.job.currentFileName)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(formatSpeed(engine.job.speedBytesPerSecond))
.font(.system(size: 12, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
}
}
.transition(.opacity)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed)
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.green
case .failed: return AppTheme.red
default: return AppTheme.blue
case .completed: return AppTheme.positive
case .failed: return AppTheme.destructive
default: return AppTheme.ink
}
}
private var percentText: String {
"\(Int(engine.job.progress * 100))%"
}
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Progress detail
private var progressDetailRow: some View {
VStack(spacing: 8) {
ProgressView(value: engine.job.progress)
.tint(AppTheme.blue)
HStack {
Text(engine.job.currentFileName)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(formatSpeed(engine.job.speedBytesPerSecond))
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
}
}
}
// MARK: Completion
private var completionSummary: some View {
HStack(spacing: 24) {
statPill(count: engine.job.uploadedFiles, label: "uploaded", color: AppTheme.green)
statPill(count: engine.job.skippedFiles, label: "skipped", color: AppTheme.textSecondary)
statPill(count: engine.job.failedFiles, label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.red : AppTheme.textTertiary)
}
}
private func statPill(count: Int, label: String, color: Color) -> some View {
private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View {
VStack(spacing: 2) {
Text("\(count)")
.font(.system(size: 20, weight: .bold))
.foregroundColor(color)
Text(value)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(color)
Text(label)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
// MARK: NAS status row
private var dividerLine: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(width: 0.5)
.padding(.vertical, 12)
}
private var nasStatusRow: some View {
HStack {
Image(systemName: "externaldrive.fill")
.foregroundColor(AppTheme.blue)
if let conn = store.savedConnection {
Text("\(conn.host)\(conn.username)")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
// MARK: NAS row
private var nasRow: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 32, height: 32)
Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
if let conn = store.savedConnection {
VStack(alignment: .leading, spacing: 1) {
Text(conn.host)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text(conn.username)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
}
}
Spacer()
HStack(spacing: 4) {
Circle().fill(AppTheme.green).frame(width: 8, height: 8)
HStack(spacing: 5) {
Circle()
.fill(AppTheme.positive)
.frame(width: 6, height: 6)
Text("Live")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.green)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.positive)
}
}
.padding(14)
.background(Color(hex: "#F9FAFB"))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.cardBorder))
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
// MARK: Options card
// MARK: Options
private var optionsCard: some View {
VStack(spacing: 0) {
optionRow(icon: "camera", title: "What to back up") {}
Divider().padding(.leading, 44)
optionRow(icon: "doc.badge.clock", title: "New files only") {}
Divider().padding(.leading, 44)
// Photos access row
optionRow(icon: "camera", label: "What to back up") {}
hairline
optionRow(icon: "doc.badge.clock", label: "New files only") {}
hairline
Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack {
Image(systemName: "photo.on.rectangle")
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.blue : AppTheme.red)
.frame(width: 28)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(vm.photosAuthStatus == .authorized
? AppTheme.inkSecondary : AppTheme.destructive)
.frame(width: 20)
Text("Photos access")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer()
Text(photosAccessLabel)
.font(AppTheme.captionFont)
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.green : AppTheme.red)
Text(photosLabel)
.font(AppTheme.caption())
.foregroundStyle(vm.photosAuthStatus == .authorized
? AppTheme.positive : AppTheme.destructive)
Image(systemName: "chevron.right")
.font(.system(size: 13))
.foregroundColor(AppTheme.textTertiary)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.buttonStyle(ScaleButtonStyle())
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder))
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
private var photosAccessLabel: String {
private var photosLabel: String {
switch vm.photosAuthStatus {
case .authorized: return "Full access"
case .limited: return "Limited"
@@ -206,26 +226,35 @@ struct BackupView: View {
}
}
private func optionRow(icon: String, title: String, action: @escaping () -> Void) -> some View {
private var hairline: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 36)
}
private func optionRow(icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Image(systemName: icon)
.foregroundColor(AppTheme.blue)
.frame(width: 28)
Text(title)
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Text(label)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13))
.foregroundColor(AppTheme.textTertiary)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.buttonStyle(ScaleButtonStyle())
}
// MARK: Action button
// MARK: Action button
private var actionButton: some View {
Group {
@@ -233,69 +262,50 @@ struct BackupView: View {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(engine.job.status == .completed ? "Back up again" : "Start backup")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(engine.job.status == .completed ? AppTheme.green : AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(PrimaryButtonStyle(
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
))
case .running:
Button(action: { engine.pause() }) {
Text("Pause")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(Color(hex: "#1E3A5F"))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
case .paused:
HStack(spacing: 12) {
Button(action: { engine.resume() }) {
Text("Resume")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
Button(action: { engine.cancel() }) {
Text("Cancel")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(AppTheme.red)
.frame(height: 52)
.frame(width: 90)
.overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red))
}
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
}
case .preparing:
HStack {
HStack(spacing: 8) {
ProgressView()
.scaleEffect(0.8)
Text("Preparing…")
.foregroundColor(AppTheme.textSecondary)
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
}
.frame(height: 52)
.frame(height: 50)
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: engine.job.status == .running)
}
private func startBackup() {
guard let conn = store.savedConnection else { return }
Task {
_ = try? await engine.run(connection: conn, filter: store.backupFilter)
}
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
}
private func formatSpeed(_ bps: Double) -> String {
let mbps = bps / 1_000_000
return mbps >= 1 ? String(format: "%.1f MB/s", mbps) : String(format: "%.0f KB/s", bps / 1000)
bps >= 1_000_000
? String(format: "%.1f MB/s", bps / 1_000_000)
: String(format: "%.0f KB/s", bps / 1_000)
}
private func etaString(_ seconds: TimeInterval) -> String {
let m = Int(seconds) / 60
let s = Int(seconds) % 60
return m > 0 ? "\(m) min" : "\(s)s"
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
}
}