Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors, SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2), PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager, all feature UIs (Login, Connect, Browse, Backup, History, Settings), Shared components and theme. Builds clean with zero errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
38
Shared/Components/FolderRow.swift
Normal file
38
Shared/Components/FolderRow.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FolderRow: View {
|
||||
let item: NASItem
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
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))
|
||||
|
||||
Text(item.name)
|
||||
.font(AppTheme.bodyFont)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
.foregroundColor(isSelected ? .white : AppTheme.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
} else if item.isDirectory {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(AppTheme.textTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(isSelected ? AppTheme.blue : Color.clear)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
26
Shared/Components/ProgressRing.swift
Normal file
26
Shared/Components/ProgressRing.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProgressRing: View {
|
||||
let progress: Double
|
||||
var lineWidth: CGFloat = 14
|
||||
var size: CGFloat = 200
|
||||
var color: Color = AppTheme.blue
|
||||
var backgroundColor: Color = Color(hex: "#E5E7EB")
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(backgroundColor, lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(min(progress, 1.0)))
|
||||
.stroke(
|
||||
color,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.4), value: progress)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
35
Shared/Components/ToggleRow.swift
Normal file
35
Shared/Components/ToggleRow.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
@Binding var isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(AppTheme.blue)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(AppTheme.bodyFont)
|
||||
.foregroundColor(AppTheme.textPrimary)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(AppTheme.captionFont)
|
||||
.foregroundColor(AppTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(AppTheme.blue)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
82
Shared/Persistence/ConnectionStore.swift
Normal file
82
Shared/Persistence/ConnectionStore.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
private func ud<T: Decodable>(_ type: T.Type, key: String) -> T? {
|
||||
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
private func saveUD<T: Encodable>(_ value: T?, key: String) {
|
||||
guard let value else { UserDefaults.standard.removeObject(forKey: key); return }
|
||||
if let data = try? JSONEncoder().encode(value) { UserDefaults.standard.set(data, forKey: key) }
|
||||
}
|
||||
|
||||
final class ConnectionStore: ObservableObject {
|
||||
static let shared = ConnectionStore()
|
||||
|
||||
@Published var savedConnection: NASConnection? {
|
||||
didSet { saveUD(savedConnection, key: "savedConnection") }
|
||||
}
|
||||
@Published var backupFilter: BackupFilter {
|
||||
didSet { saveUD(backupFilter, key: "backupFilter") }
|
||||
}
|
||||
@Published var trustedSSIDs: [String] {
|
||||
didSet { UserDefaults.standard.set(trustedSSIDs, forKey: "trustedSSIDs") }
|
||||
}
|
||||
@Published var lanTriggerEnabled: Bool {
|
||||
didSet { UserDefaults.standard.set(lanTriggerEnabled, forKey: "lanTriggerEnabled") }
|
||||
}
|
||||
@Published var startDelaySeconds: Int {
|
||||
didSet { UserDefaults.standard.set(startDelaySeconds, forKey: "startDelaySeconds") }
|
||||
}
|
||||
@Published var chargingOnlyMode: Bool {
|
||||
didSet { UserDefaults.standard.set(chargingOnlyMode, forKey: "chargingOnlyMode") }
|
||||
}
|
||||
@Published var quietHoursEnabled: Bool {
|
||||
didSet { UserDefaults.standard.set(quietHoursEnabled, forKey: "quietHoursEnabled") }
|
||||
}
|
||||
@Published var quietHoursStart: Int {
|
||||
didSet { UserDefaults.standard.set(quietHoursStart, forKey: "quietHoursStart") }
|
||||
}
|
||||
@Published var quietHoursEnd: Int {
|
||||
didSet { UserDefaults.standard.set(quietHoursEnd, forKey: "quietHoursEnd") }
|
||||
}
|
||||
@Published private(set) var historyEntries: [BackupHistoryEntry] = []
|
||||
|
||||
private init() {
|
||||
savedConnection = ud(NASConnection.self, key: "savedConnection")
|
||||
backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter()
|
||||
trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? []
|
||||
lanTriggerEnabled = UserDefaults.standard.object(forKey: "lanTriggerEnabled") as? Bool ?? true
|
||||
startDelaySeconds = UserDefaults.standard.object(forKey: "startDelaySeconds") as? Int ?? 30
|
||||
chargingOnlyMode = UserDefaults.standard.object(forKey: "chargingOnlyMode") as? Bool ?? true
|
||||
quietHoursEnabled = UserDefaults.standard.object(forKey: "quietHoursEnabled") as? Bool ?? false
|
||||
quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23
|
||||
quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7
|
||||
historyEntries = ud([BackupHistoryEntry].self, key: "historyEntries") ?? []
|
||||
}
|
||||
|
||||
func appendHistoryEntry(_ entry: BackupHistoryEntry) {
|
||||
historyEntries.insert(entry, at: 0)
|
||||
saveUD(historyEntries, key: "historyEntries")
|
||||
}
|
||||
|
||||
func removeHistoryEntries(at offsets: IndexSet) {
|
||||
historyEntries.remove(atOffsets: offsets)
|
||||
saveUD(historyEntries, key: "historyEntries")
|
||||
}
|
||||
|
||||
func clearHistory() {
|
||||
historyEntries = []
|
||||
UserDefaults.standard.removeObject(forKey: "historyEntries")
|
||||
}
|
||||
|
||||
var isInQuietHours: Bool {
|
||||
guard quietHoursEnabled else { return false }
|
||||
let hour = Calendar.current.component(.hour, from: Date())
|
||||
if quietHoursStart > quietHoursEnd {
|
||||
return hour >= quietHoursStart || hour < quietHoursEnd
|
||||
}
|
||||
return hour >= quietHoursStart && hour < quietHoursEnd
|
||||
}
|
||||
}
|
||||
48
Shared/Theme/AppTheme.swift
Normal file
48
Shared/Theme/AppTheme.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
static let background = Color.white
|
||||
static let cardBackground = Color.white
|
||||
static let cardBorder = Color(hex: "#E5E7EB")
|
||||
|
||||
// Text
|
||||
static let textPrimary = Color(hex: "#111827")
|
||||
static let textSecondary = Color(hex: "#6B7280")
|
||||
static let textTertiary = Color(hex: "#9CA3AF")
|
||||
|
||||
// Status
|
||||
static let connectedBorder = Color(hex: "#16A34A")
|
||||
static let lanBorder = Color(hex: "#2563EB")
|
||||
|
||||
// Typography
|
||||
static let titleFont = Font.system(size: 28, weight: .bold, design: .default)
|
||||
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 cardCornerRadius: CGFloat = 12
|
||||
static let cardPadding: CGFloat = 16
|
||||
static let sectionSpacing: CGFloat = 24
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user