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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ringColor: Color {
|
||||
switch engine.job.status {
|
||||
case .completed: return AppTheme.green
|
||||
case .failed: return AppTheme.red
|
||||
default: return AppTheme.blue
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(formatSpeed(engine.job.speedBytesPerSecond))
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.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.positive
|
||||
case .failed: return AppTheme.destructive
|
||||
default: return AppTheme.ink
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Completion
|
||||
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
HStack(spacing: 10) {
|
||||
Button { engine.resume() } label: { Text("Resume") }
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
Button { engine.cancel() } label: { Text("Cancel") }
|
||||
.buttonStyle(GhostButtonStyle())
|
||||
.frame(width: 90)
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red))
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ struct BrowseView: View {
|
||||
@State private var showCreateFolder = false
|
||||
@State private var newFolderName = ""
|
||||
|
||||
private var service: any NASTransferProtocol {
|
||||
connection.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
@@ -23,36 +19,39 @@ struct BrowseView: View {
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Path bar
|
||||
HStack {
|
||||
Image(systemName: "externaldrive.fill")
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(.system(size: 13))
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "externaldrive")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
Text(currentPath)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(hex: "#F9FAFB"))
|
||||
.background(AppTheme.surfaceSunken)
|
||||
|
||||
Divider()
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
|
||||
if isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(AppTheme.inkTertiary)
|
||||
Spacer()
|
||||
} else if let error {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.font(.system(size: 28, weight: .light))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(error)
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
@@ -61,14 +60,22 @@ struct BrowseView: View {
|
||||
List {
|
||||
if currentPath != "/" {
|
||||
Button(action: navigateUp) {
|
||||
HStack {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "arrow.up")
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 22)
|
||||
Text("Parent folder")
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
ForEach(items) { item in
|
||||
@@ -83,7 +90,7 @@ struct BrowseView: View {
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -95,6 +102,8 @@ struct BrowseView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
@@ -102,6 +111,7 @@ struct BrowseView: View {
|
||||
showCreateFolder = true
|
||||
} label: {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
@@ -109,8 +119,8 @@ struct BrowseView: View {
|
||||
selectedPath = currentPath
|
||||
dismiss()
|
||||
}
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(AppTheme.blue)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
}
|
||||
}
|
||||
.alert("New folder", isPresented: $showCreateFolder) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct ConnectView: View {
|
||||
@StateObject private var vm = ConnectViewModel()
|
||||
@State private var navigateToDashboard = false
|
||||
@FocusState private var focused: ConnectField?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -15,109 +16,143 @@ struct ConnectView: View {
|
||||
Image("nas_logo_clean")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.top, 48)
|
||||
.padding(.bottom, 28)
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.padding(.top, 44)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// Protocol picker
|
||||
Picker("Protocol", selection: $vm.selectedProtocol) {
|
||||
ForEach(NASProtocol.allCases, id: \.self) { Text($0.rawValue).tag($0) }
|
||||
HStack(spacing: 0) {
|
||||
ForEach(NASProtocol.allCases, id: \.self) { proto in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
|
||||
vm.selectedProtocol = proto
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
} label: {
|
||||
Text(proto.rawValue)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(vm.selectedProtocol == proto ? Color.white : Color.clear)
|
||||
.shadow(
|
||||
color: vm.selectedProtocol == proto ? .black.opacity(0.07) : .clear,
|
||||
radius: 4, x: 0, y: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// Form card
|
||||
VStack(spacing: 0) {
|
||||
fieldRow(icon: "network", placeholder: "IP Address or hostname",
|
||||
text: $vm.host, keyboardType: .URL)
|
||||
Divider().padding(.leading, 52)
|
||||
fieldRow(icon: "person", placeholder: "Username",
|
||||
text: $vm.username, keyboardType: .emailAddress)
|
||||
Divider().padding(.leading, 52)
|
||||
secureFieldRow(icon: "lock", placeholder: "Password", text: $vm.password)
|
||||
Divider().padding(.leading, 52)
|
||||
|
||||
// Folder picker row
|
||||
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "folder")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(vm.isConnected ? AppTheme.blue : AppTheme.textTertiary)
|
||||
.frame(width: 28)
|
||||
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath)
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(vm.remotePath == "/" ? AppTheme.textTertiary : AppTheme.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
formField(
|
||||
icon: "network",
|
||||
placeholder: "IP address or hostname",
|
||||
text: $vm.host,
|
||||
field: .host,
|
||||
keyboard: .URL
|
||||
)
|
||||
hairline
|
||||
formField(
|
||||
icon: "person",
|
||||
placeholder: "Username",
|
||||
text: $vm.username,
|
||||
field: .username,
|
||||
keyboard: .emailAddress
|
||||
)
|
||||
hairline
|
||||
secureField(
|
||||
icon: "lock",
|
||||
placeholder: "Password",
|
||||
text: $vm.password,
|
||||
field: .password
|
||||
)
|
||||
hairline
|
||||
folderRow
|
||||
}
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
||||
.stroke(vm.isConnected ? AppTheme.connectedBorder :
|
||||
(vm.verifyError != nil ? AppTheme.red : AppTheme.cardBorder),
|
||||
lineWidth: vm.isConnected ? 1.5 : 1)
|
||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||
.stroke(
|
||||
vm.isConnected ? AppTheme.positive.opacity(0.4) :
|
||||
vm.verifyError != nil ? AppTheme.destructive.opacity(0.4) :
|
||||
AppTheme.cardBorderColor,
|
||||
lineWidth: vm.isConnected || vm.verifyError != nil ? 1 : 0.5
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
)
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError != nil)
|
||||
|
||||
// Feedback
|
||||
Group {
|
||||
if let error = vm.verifyError {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 13))
|
||||
Text(error)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.red)
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, 24)
|
||||
.font(AppTheme.caption())
|
||||
}
|
||||
|
||||
if vm.isConnected {
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else if vm.isConnected {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 13))
|
||||
Text("Connected successfully")
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.green)
|
||||
.padding(.top, 10)
|
||||
.font(AppTheme.caption())
|
||||
}
|
||||
.foregroundStyle(AppTheme.positive)
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||
|
||||
// Connect button — sits BELOW the card with explicit gap
|
||||
Spacer().frame(height: 24)
|
||||
// Button — below the card with deliberate gap
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
Button(action: {
|
||||
if vm.isConnected {
|
||||
navigateToDashboard = true
|
||||
} else {
|
||||
Task { await vm.verify() }
|
||||
}
|
||||
if vm.isConnected { navigateToDashboard = true }
|
||||
else { Task { await vm.verify() } }
|
||||
}) {
|
||||
Group {
|
||||
if vm.isVerifying {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text(vm.isConnected ? "Connected — continue" : "Connect")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Text(vm.isConnected ? "Continue" : "Connect")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(vm.isConnected ? AppTheme.green : AppTheme.blue)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle(
|
||||
color: vm.isConnected ? AppTheme.positive : AppTheme.ink
|
||||
))
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.disabled(!vm.canConnect)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 40)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect to NAS")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(isPresented: $navigateToDashboard) {
|
||||
MainTabView()
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
||||
.sheet(isPresented: $vm.showFolderBrowser) {
|
||||
if let conn = ConnectionStore.shared.savedConnection {
|
||||
BrowseView(connection: conn, selectedPath: $vm.remotePath)
|
||||
@@ -126,30 +161,76 @@ struct ConnectView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldRow(icon: String, placeholder: String, text: Binding<String>, keyboardType: UIKeyboardType = .default) -> some View {
|
||||
// MARK: — Sub-views
|
||||
|
||||
private var hairline: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
|
||||
private var folderRow: some View {
|
||||
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "folder")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(vm.isConnected ? AppTheme.inkSecondary : AppTheme.inkQuaternary)
|
||||
.frame(width: 20)
|
||||
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(vm.remotePath == "/" ? AppTheme.inkTertiary : AppTheme.ink)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
|
||||
private func formField(
|
||||
icon: String,
|
||||
placeholder: String,
|
||||
text: Binding<String>,
|
||||
field: ConnectField,
|
||||
keyboard: UIKeyboardType
|
||||
) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.frame(width: 28)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
TextField(placeholder, text: text)
|
||||
.font(AppTheme.bodyFont)
|
||||
.keyboardType(keyboardType)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.keyboardType(keyboard)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focused, equals: field)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private func secureFieldRow(icon: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
private func secureField(
|
||||
icon: String,
|
||||
placeholder: String,
|
||||
text: Binding<String>,
|
||||
field: ConnectField
|
||||
) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.frame(width: 28)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
SecureField(placeholder, text: text)
|
||||
.font(AppTheme.bodyFont)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.focused($focused, equals: field)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
@@ -8,20 +8,20 @@ struct HistoryView: View {
|
||||
Color.white.ignoresSafeArea()
|
||||
|
||||
if store.historyEntries.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text("No backup history yet")
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(store.historyEntries) { entry in
|
||||
HistoryRowView(entry: entry)
|
||||
.listRowSeparator(.visible)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
.onDelete { offsets in
|
||||
store.removeHistoryEntries(at: offsets)
|
||||
@@ -35,7 +35,8 @@ struct HistoryView: View {
|
||||
if !store.historyEntries.isEmpty {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Clear") { store.clearHistory() }
|
||||
.foregroundColor(AppTheme.red)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,53 +46,79 @@ struct HistoryView: View {
|
||||
struct HistoryRowView: View {
|
||||
let entry: BackupHistoryEntry
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Circle()
|
||||
.fill(entry.failedCount == 0 ? AppTheme.green : AppTheme.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
private var hasErrors: Bool { entry.failedCount > 0 }
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Circle()
|
||||
.fill(hasErrors ? AppTheme.destructive : AppTheme.positive)
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.top, 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(entry.date, style: .date)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
|
||||
if entry.triggeredByLAN {
|
||||
Text("AUTO")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(AppTheme.blue)
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(AppTheme.blue, lineWidth: 1))
|
||||
}
|
||||
Spacer()
|
||||
Text(entry.date, style: .time)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
|
||||
)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
statChip("\(entry.uploadedCount) uploaded", color: AppTheme.textSecondary)
|
||||
if entry.skippedCount > 0 {
|
||||
statChip("\(entry.skippedCount) skipped", color: AppTheme.textTertiary)
|
||||
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)
|
||||
|
||||
if entry.skippedCount > 0 {
|
||||
dot
|
||||
Text("\(entry.skippedCount) skipped")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
if entry.failedCount > 0 {
|
||||
statChip("\(entry.failedCount) errors", color: AppTheme.red)
|
||||
dot
|
||||
Text("\(entry.failedCount) errors")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
}
|
||||
}
|
||||
|
||||
Text(entry.nasHost)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.font(AppTheme.micro(11))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 37)
|
||||
}
|
||||
}
|
||||
|
||||
private func statChip(_ text: String, color: Color) -> some View {
|
||||
Text(text)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(color)
|
||||
private var dot: some View {
|
||||
Circle()
|
||||
.fill(AppTheme.inkQuaternary)
|
||||
.frame(width: 3, height: 3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct LoginView: View {
|
||||
@StateObject private var vm = LoginViewModel()
|
||||
@State private var navigateToDashboard = false
|
||||
@State private var logoAppeared = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -12,88 +13,96 @@ struct LoginView: View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Logo + device
|
||||
VStack(spacing: 16) {
|
||||
// Logo
|
||||
Image("nas_logo_clean")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 100, height: 100)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22))
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4)
|
||||
.scaleEffect(logoAppeared ? 1 : 0.8)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: logoAppeared)
|
||||
|
||||
Spacer().frame(height: 32)
|
||||
|
||||
// Device + account
|
||||
VStack(spacing: 6) {
|
||||
Text("Forge")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.frame(width: 28, height: 28)
|
||||
Text("AB")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
Text("Albert")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
}
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
// Account avatar + name
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppTheme.blue)
|
||||
.frame(width: 48, height: 48)
|
||||
Text("AB")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Albert")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
Text("NAS Account")
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
|
||||
// Face ID button
|
||||
Button(action: { vm.authenticateWithFaceID() }) {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
if vm.isAuthenticating {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text("Sign in with Face ID")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(AppTheme.blue)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.disabled(vm.isAuthenticating)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared)
|
||||
|
||||
if let error = vm.authError {
|
||||
Text(error)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.red)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
.padding(.top, 12)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// More link
|
||||
Button("More") { vm.showMoreSheet = true }
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.padding(.bottom, 40)
|
||||
// More
|
||||
Button("More options") { vm.showMoreSheet = true }
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.padding(.bottom, 44)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToDashboard) {
|
||||
MainTabView()
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
||||
.sheet(isPresented: $vm.showMoreSheet) {
|
||||
MoreOptionsSheet(isPresented: $vm.showMoreSheet, onAuthenticated: {
|
||||
MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
|
||||
vm.showMoreSheet = false
|
||||
navigateToDashboard = true
|
||||
})
|
||||
.presentationDetents([.height(220)])
|
||||
}
|
||||
.presentationDetents([.height(200)])
|
||||
.presentationCornerRadius(20)
|
||||
}
|
||||
.onAppear {
|
||||
vm.onAuthenticated = { navigateToDashboard = true }
|
||||
logoAppeared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,41 +110,51 @@ struct LoginView: View {
|
||||
struct MoreOptionsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
var onAuthenticated: (() -> Void)?
|
||||
@State private var showPasswordEntry = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(Color(hex: "#D1D5DB"))
|
||||
.frame(width: 40, height: 4)
|
||||
.fill(AppTheme.inkQuaternary)
|
||||
.frame(width: 32, height: 4)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Button("Password") { showPasswordEntry = true }
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Another account") {
|
||||
VStack(spacing: 0) {
|
||||
sheetRow("Password", weight: .medium) { }
|
||||
Divider().padding(.leading, 16)
|
||||
sheetRow("Another account") {
|
||||
isPresented = false
|
||||
onAuthenticated?()
|
||||
}
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Cancel") { isPresented = false }
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
Divider().padding(.leading, 16)
|
||||
sheetRow("Cancel", color: AppTheme.inkTertiary) { isPresented = false }
|
||||
}
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||
.stroke(AppTheme.cardBorderColor, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.background(Color.white)
|
||||
}
|
||||
|
||||
private func sheetRow(
|
||||
_ title: String,
|
||||
weight: Font.Weight = .regular,
|
||||
color: Color = AppTheme.ink,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: weight))
|
||||
.foregroundStyle(color)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ struct iPhoneTabLayout: View {
|
||||
NavigationStack { SettingsView() }
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
}
|
||||
.tint(AppTheme.blue)
|
||||
.tint(AppTheme.ink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,6 @@ struct iPadSidebarLayout: View {
|
||||
default: BackupView()
|
||||
}
|
||||
}
|
||||
.tint(AppTheme.blue)
|
||||
.tint(AppTheme.ink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,147 +6,150 @@ struct LANTriggerSection: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("AUTOMATION — LAN TRIGGER")
|
||||
.font(AppTheme.smallFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.padding(.leading, 4)
|
||||
sectionLabel("AUTOMATION")
|
||||
|
||||
// Hero card — blue border, FIXED 210px height, strict top-down layout
|
||||
VStack(spacing: 0) {
|
||||
// Header band
|
||||
HStack {
|
||||
// Master toggle row
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
.fill(store.lanTriggerEnabled ? AppTheme.ink : AppTheme.surfaceSunken)
|
||||
.frame(width: 28, height: 28)
|
||||
Image(systemName: "wifi")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
Text("Auto-backup on home network")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(store.lanTriggerEnabled ? "On" : "Off")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(store.lanTriggerEnabled ? Color.white.opacity(0.25) : Color.white.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(store.lanTriggerEnabled ? .white : AppTheme.inkTertiary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(AppTheme.blue)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: store.lanTriggerEnabled)
|
||||
|
||||
// Toggle row
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Start when on trusted Wi-Fi")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
Text("Backup runs on SSID match")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Auto-backup on home network")
|
||||
.font(AppTheme.headline())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("Starts when a trusted Wi-Fi is joined")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $store.lanTriggerEnabled)
|
||||
.labelsHidden()
|
||||
.tint(AppTheme.blue)
|
||||
.scaleEffect(0.85)
|
||||
.tint(AppTheme.ink)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 6)
|
||||
.padding(AppTheme.cardPad)
|
||||
|
||||
Divider().padding(.horizontal, 14)
|
||||
hairline
|
||||
|
||||
// Trusted networks
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("TRUSTED NETWORKS")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 6)
|
||||
.font(AppTheme.micro(10))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.top, 10)
|
||||
|
||||
ForEach(store.trustedSSIDs, id: \.self) { ssid in
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(ssid == lanMonitor.currentSSID ? AppTheme.green : AppTheme.textTertiary)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(ssid == lanMonitor.currentSSID ? "Connected" : "Saved")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.frame(width: 54, alignment: .leading)
|
||||
.fill(ssid == lanMonitor.currentSSID ? AppTheme.positive : AppTheme.inkQuaternary)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(ssid)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
if ssid == lanMonitor.currentSSID {
|
||||
Text("connected")
|
||||
.font(AppTheme.micro(10))
|
||||
.foregroundStyle(AppTheme.positive)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
|
||||
store.trustedSSIDs.removeAll { $0 == ssid }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
if let ssid = await lanMonitor.fetchCurrentSSID(),
|
||||
!store.trustedSSIDs.contains(ssid) {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
store.trustedSSIDs.append(ssid)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AppTheme.blue)
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text("Add current network")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(AppTheme.blue)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 4)
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Divider().padding(.horizontal, 14).padding(.top, 4)
|
||||
hairline
|
||||
|
||||
// Bottom options row
|
||||
// Delay picker
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
Text("Wait before starting")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
}
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach([0, 15, 30, 60, 120], id: \.self) { sec in
|
||||
Button("\(sec)s") { store.startDelaySeconds = sec }
|
||||
Button("\(sec == 0 ? "Immediately" : "\(sec)s")") {
|
||||
store.startDelaySeconds = sec
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("\(store.startDelaySeconds)s")
|
||||
Text(store.startDelaySeconds == 0 ? "Immediately" : "\(store.startDelaySeconds)s")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(AppTheme.blue)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(height: 210)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
||||
.stroke(AppTheme.lanBorder, lineWidth: 1.5)
|
||||
)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var hairline: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, AppTheme.cardPad)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Shared section header
|
||||
func sectionLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.kerning(0.5)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
@@ -8,101 +8,85 @@ struct SettingsView: View {
|
||||
Color.white.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: AppTheme.sectionSpacing) {
|
||||
// LAN trigger hero section
|
||||
VStack(spacing: AppTheme.sectionGap) {
|
||||
LANTriggerSection()
|
||||
|
||||
// What to back up
|
||||
mediaSection
|
||||
|
||||
// Quiet hours
|
||||
quietHoursSection
|
||||
|
||||
// Notification section
|
||||
notificationSection
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: Media section
|
||||
// MARK: — Media
|
||||
|
||||
private var mediaSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("WHAT TO BACK UP")
|
||||
.font(AppTheme.smallFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.padding(.leading, 4)
|
||||
sectionLabel("WHAT TO BACK UP")
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ToggleRow(icon: "photo", title: "Photos",
|
||||
isOn: $store.backupFilter.includePhotos)
|
||||
Divider().padding(.leading, 44)
|
||||
hairline
|
||||
ToggleRow(icon: "video", title: "Videos",
|
||||
isOn: $store.backupFilter.includeVideos)
|
||||
Divider().padding(.leading, 44)
|
||||
hairline
|
||||
ToggleRow(icon: "camera.viewfinder", title: "Screenshots",
|
||||
isOn: $store.backupFilter.includeScreenshots)
|
||||
Divider().padding(.leading, 44)
|
||||
hairline
|
||||
ToggleRow(icon: "rays", title: "RAW",
|
||||
isOn: $store.backupFilter.includeRAW)
|
||||
Divider().padding(.leading, 44)
|
||||
hairline
|
||||
ToggleRow(icon: "doc.badge.clock", title: "New files only",
|
||||
subtitle: "Skip files already on NAS",
|
||||
isOn: $store.backupFilter.newFilesOnly)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Quiet hours
|
||||
// MARK: — Quiet hours
|
||||
|
||||
private var quietHoursSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("QUIET HOURS")
|
||||
.font(AppTheme.smallFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.padding(.leading, 4)
|
||||
sectionLabel("QUIET HOURS")
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ToggleRow(icon: "moon.fill", title: "Quiet hours",
|
||||
ToggleRow(icon: "moon", title: "Quiet hours",
|
||||
subtitle: "No auto-backup during this window",
|
||||
isOn: $store.quietHoursEnabled)
|
||||
|
||||
if store.quietHoursEnabled {
|
||||
Divider().padding(.leading, 44)
|
||||
hairline
|
||||
timePickerRow(label: "Start", hour: $store.quietHoursStart)
|
||||
Divider().padding(.leading, 44)
|
||||
hairline
|
||||
timePickerRow(label: "End", hour: $store.quietHoursEnd)
|
||||
}
|
||||
|
||||
Divider().padding(.leading, 44)
|
||||
ToggleRow(icon: "bolt.fill", title: "Only while charging",
|
||||
hairline
|
||||
ToggleRow(icon: "bolt", title: "Only while charging",
|
||||
subtitle: "Preserve battery on large libraries",
|
||||
isOn: $store.chargingOnlyMode)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.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)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func timePickerRow(label: String, hour: Binding<Int>) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Spacer()
|
||||
Picker(label, selection: hour) {
|
||||
ForEach(0..<24, id: \.self) { h in
|
||||
@@ -110,47 +94,53 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.tint(AppTheme.blue)
|
||||
.tint(AppTheme.inkSecondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
// MARK: — Notifications
|
||||
|
||||
private var notificationSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("NOTIFICATIONS")
|
||||
.font(AppTheme.smallFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.padding(.leading, 4)
|
||||
sectionLabel("NOTIFICATIONS")
|
||||
|
||||
VStack(spacing: 0) {
|
||||
staticRow(icon: "bell.fill", title: "On start", value: "Off")
|
||||
Divider().padding(.leading, 44)
|
||||
staticRow(icon: "checkmark.circle.fill", title: "On complete", value: "On")
|
||||
Divider().padding(.leading, 44)
|
||||
staticRow(icon: "exclamationmark.triangle.fill", title: "On errors", value: "On")
|
||||
notifRow(icon: "bell", title: "On start", value: "Off")
|
||||
hairline
|
||||
notifRow(icon: "checkmark.circle", title: "On complete", value: "On")
|
||||
hairline
|
||||
notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.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 func staticRow(icon: String, title: String, value: String) -> some View {
|
||||
HStack {
|
||||
private func notifRow(icon: String, title: String, value: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(AppTheme.blue)
|
||||
.frame(width: 28)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
Text(title)
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private var hairline: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, AppTheme.cardPad)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; };
|
||||
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
|
||||
C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; };
|
||||
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
|
||||
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||
@@ -307,7 +307,6 @@
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
32316B985B8906FE4CB97730 = {
|
||||
DevelopmentTeam = "";
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
@@ -383,6 +382,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -467,6 +467,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
63
Shared/Components/ButtonStyles.swift
Normal file
63
Shared/Components/ButtonStyles.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: — Primary (black fill)
|
||||
struct PrimaryButtonStyle: ButtonStyle {
|
||||
var color: Color = AppTheme.ink
|
||||
var height: CGFloat = 50
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: height)
|
||||
.background(color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Ghost (outline)
|
||||
struct GhostButtonStyle: ButtonStyle {
|
||||
var height: CGFloat = 50
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(AppTheme.ink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: height)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||
.stroke(AppTheme.cardBorderColor, lineWidth: 1)
|
||||
)
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Scale (tap anywhere, just scales)
|
||||
struct ScaleButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Pill (compact, rounded)
|
||||
struct PillButtonStyle: ButtonStyle {
|
||||
var color: Color = AppTheme.ink
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(color)
|
||||
.clipShape(Capsule())
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
@@ -8,31 +8,37 @@ struct FolderRow: View {
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: item.isDirectory ? "folder.fill" : "doc.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(isSelected ? .white : (item.isDirectory ? AppTheme.blue : AppTheme.textSecondary))
|
||||
Image(systemName: item.isDirectory ? "folder" : "doc")
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundStyle(isSelected ? .white : AppTheme.inkSecondary)
|
||||
.frame(width: 22)
|
||||
|
||||
Text(item.name)
|
||||
.font(AppTheme.bodyFont)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
.foregroundColor(isSelected ? .white : AppTheme.textPrimary)
|
||||
.font(.system(size: 15, weight: isSelected ? .medium : .regular))
|
||||
.foregroundStyle(isSelected ? .white : AppTheme.ink)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
} else if item.isDirectory {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(isSelected ? AppTheme.blue : Color.clear)
|
||||
.background(
|
||||
isSelected
|
||||
? AppTheme.ink
|
||||
: Color.clear
|
||||
)
|
||||
.animation(.spring(response: 0.2, dampingFraction: 0.8), value: isSelected)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,21 @@ import SwiftUI
|
||||
|
||||
struct ProgressRing: View {
|
||||
let progress: Double
|
||||
var lineWidth: CGFloat = 14
|
||||
var lineWidth: CGFloat = 8
|
||||
var size: CGFloat = 200
|
||||
var color: Color = AppTheme.blue
|
||||
var backgroundColor: Color = Color(hex: "#E5E7EB")
|
||||
var color: Color = AppTheme.ink
|
||||
var backgroundColor: Color = Color(hex: "#EBEBEB")
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(backgroundColor, lineWidth: lineWidth)
|
||||
.stroke(backgroundColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(min(progress, 1.0)))
|
||||
.stroke(
|
||||
color,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.4), value: progress)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.8), value: progress)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
@@ -9,18 +9,18 @@ struct ToggleRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(AppTheme.blue)
|
||||
.frame(width: 24)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ struct ToggleRow: View {
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(AppTheme.blue)
|
||||
.tint(AppTheme.ink)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppTheme {
|
||||
// Brand
|
||||
static let blue = Color(hex: "#2563EB")
|
||||
static let blueLight = Color(hex: "#3B82F6")
|
||||
static let green = Color(hex: "#16A34A")
|
||||
static let greenLight = Color(hex: "#22C55E")
|
||||
static let red = Color(hex: "#DC2626")
|
||||
static let orange = Color(hex: "#EA580C")
|
||||
|
||||
// Backgrounds
|
||||
// MARK: — Surfaces
|
||||
static let background = Color.white
|
||||
static let cardBackground = Color.white
|
||||
static let cardBorder = Color(hex: "#E5E7EB")
|
||||
static let surfaceRaised = Color.white
|
||||
static let surfaceSunken = Color(hex: "#F5F5F5")
|
||||
|
||||
// Text
|
||||
static let textPrimary = Color(hex: "#111827")
|
||||
static let textSecondary = Color(hex: "#6B7280")
|
||||
static let textTertiary = Color(hex: "#9CA3AF")
|
||||
// MARK: — Ink
|
||||
static let ink = Color(hex: "#0F0F0F")
|
||||
static let inkSecondary = Color(hex: "#6B6B6B")
|
||||
static let inkTertiary = Color(hex: "#ABABAB")
|
||||
static let inkQuaternary = Color(hex: "#D4D4D4")
|
||||
|
||||
// Status
|
||||
static let connectedBorder = Color(hex: "#16A34A")
|
||||
static let lanBorder = Color(hex: "#2563EB")
|
||||
// MARK: — Semantic
|
||||
static let positive = Color(hex: "#16A34A")
|
||||
static let destructive = Color(hex: "#DC2626")
|
||||
static let interactive = Color(hex: "#2563EB")
|
||||
|
||||
// Typography
|
||||
static let titleFont = Font.system(size: 28, weight: .bold, design: .default)
|
||||
// MARK: — Chrome
|
||||
static let separator = Color.black.opacity(0.07)
|
||||
static let cardShadow = Color.black.opacity(0.05)
|
||||
static let cardBorderColor = Color.black.opacity(0.07)
|
||||
|
||||
// MARK: — Metrics
|
||||
static let radius: CGFloat = 14
|
||||
static let radiusLarge: CGFloat = 18
|
||||
static let hPad: CGFloat = 20
|
||||
static let cardPad: CGFloat = 16
|
||||
static let sectionGap: CGFloat = 28
|
||||
|
||||
// MARK: — Typography
|
||||
static func display(_ size: CGFloat = 32) -> Font {
|
||||
.system(size: size, weight: .semibold, design: .default)
|
||||
}
|
||||
static func title(_ size: CGFloat = 20) -> Font {
|
||||
.system(size: size, weight: .semibold)
|
||||
}
|
||||
static func headline(_ size: CGFloat = 15) -> Font {
|
||||
.system(size: size, weight: .medium)
|
||||
}
|
||||
static func body(_ size: CGFloat = 15) -> Font {
|
||||
.system(size: size, weight: .regular)
|
||||
}
|
||||
static func caption(_ size: CGFloat = 13) -> Font {
|
||||
.system(size: size, weight: .regular)
|
||||
}
|
||||
static func micro(_ size: CGFloat = 11) -> Font {
|
||||
.system(size: size, weight: .medium)
|
||||
}
|
||||
|
||||
// MARK: — Back-compat aliases used by existing call sites
|
||||
static let blue = interactive
|
||||
static let green = positive
|
||||
static let red = destructive
|
||||
static let textPrimary = ink
|
||||
static let textSecondary = inkSecondary
|
||||
static let textTertiary = inkTertiary
|
||||
static let cardBorder = cardBorderColor
|
||||
static let connectedBorder = positive
|
||||
static let lanBorder = interactive
|
||||
static let cardCornerRadius = radius
|
||||
static let cardBackground = surfaceRaised
|
||||
static let titleFont = Font.system(size: 28, weight: .bold)
|
||||
static let headlineFont = Font.system(size: 17, weight: .semibold)
|
||||
static let bodyFont = Font.system(size: 15, weight: .regular)
|
||||
static let captionFont = Font.system(size: 13, weight: .regular)
|
||||
static let smallFont = Font.system(size: 11, weight: .medium)
|
||||
static let sectionSpacing: CGFloat = 28
|
||||
}
|
||||
|
||||
static let cardCornerRadius: CGFloat = 12
|
||||
static let cardPadding: CGFloat = 16
|
||||
static let sectionSpacing: CGFloat = 24
|
||||
// MARK: — Card modifier
|
||||
struct CardStyle: ViewModifier {
|
||||
var padded: Bool = true
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.if(padded) { $0.padding(AppTheme.cardPad) }
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func card(padded: Bool = true) -> some View {
|
||||
modifier(CardStyle(padded: padded))
|
||||
}
|
||||
|
||||
@ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
|
||||
if condition { transform(self) } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
@@ -40,9 +96,10 @@ extension Color {
|
||||
var h = hex.trimmingCharacters(in: .init(charactersIn: "#"))
|
||||
if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() }
|
||||
let n = UInt64(h, radix: 16) ?? 0
|
||||
let r = Double((n >> 16) & 0xFF) / 255
|
||||
let g = Double((n >> 8) & 0xFF) / 255
|
||||
let b = Double(n & 0xFF) / 255
|
||||
self.init(red: r, green: g, blue: b)
|
||||
self.init(
|
||||
red: Double((n >> 16) & 0xFF) / 255,
|
||||
green: Double((n >> 8) & 0xFF) / 255,
|
||||
blue: Double( n & 0xFF) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user