Add premium backups with retention and iCloud support
This commit is contained in:
@@ -34,6 +34,16 @@ struct PortfolioJournalApp: App {
|
|||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
coreDataStack.refreshWidgetData()
|
coreDataStack.refreshWidgetData()
|
||||||
|
} else if newPhase == .background {
|
||||||
|
guard iapService.isPremium else { return }
|
||||||
|
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }
|
||||||
|
let retention = UserDefaults.standard.integer(forKey: "backupRetentionCount")
|
||||||
|
let keepCount = [5, 10, 20].contains(retention) ? retention : 10
|
||||||
|
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
|
||||||
|
_ = BackupService.shared.createBackup(
|
||||||
|
retentionCount: keepCount,
|
||||||
|
includeICloud: includeICloud
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
enum BackupLocation: String {
|
||||||
|
case local = "On device"
|
||||||
|
case iCloud = "iCloud"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackupRecord: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let url: URL
|
||||||
|
let date: Date
|
||||||
|
let size: Int64
|
||||||
|
let location: BackupLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupService {
|
||||||
|
static let shared = BackupService()
|
||||||
|
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
private let dateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd-HHmmss"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] {
|
||||||
|
let timestamp = dateFormatter.string(from: Date())
|
||||||
|
let fileName = "backup-\(timestamp).json"
|
||||||
|
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
let sources = fetchAllSources(in: context)
|
||||||
|
let categories = fetchAllCategories(in: context)
|
||||||
|
let content = ExportService.shared.exportToJSON(sources: sources, categories: categories)
|
||||||
|
|
||||||
|
var records: [BackupRecord] = []
|
||||||
|
|
||||||
|
if let localDir = backupDirectory() {
|
||||||
|
let localURL = localDir.appendingPathComponent(fileName)
|
||||||
|
write(content: content, to: localURL)
|
||||||
|
pruneBackups(in: localDir, keep: retentionCount, location: .local)
|
||||||
|
records.append(contentsOf: listBackups(in: localDir, location: .local))
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
|
||||||
|
let iCloudURL = iCloudDir.appendingPathComponent(fileName)
|
||||||
|
write(content: content, to: iCloudURL)
|
||||||
|
pruneBackups(in: iCloudDir, keep: retentionCount, location: .iCloud)
|
||||||
|
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAllBackups(includeICloud: Bool) -> [BackupRecord] {
|
||||||
|
var records: [BackupRecord] = []
|
||||||
|
if let localDir = backupDirectory() {
|
||||||
|
records.append(contentsOf: listBackups(in: localDir, location: .local))
|
||||||
|
}
|
||||||
|
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
|
||||||
|
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
|
||||||
|
}
|
||||||
|
return records.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func backupDirectory() -> URL? {
|
||||||
|
guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let dir = base.appendingPathComponent("Backups", isDirectory: true)
|
||||||
|
ensureDirectoryExists(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iCloudBackupDirectory() -> URL? {
|
||||||
|
guard let base = fileManager.url(forUbiquityContainerIdentifier: nil) else { return nil }
|
||||||
|
let dir = base.appendingPathComponent("Documents/Backups", isDirectory: true)
|
||||||
|
ensureDirectoryExists(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureDirectoryExists(_ url: URL) {
|
||||||
|
if !fileManager.fileExists(atPath: url.path) {
|
||||||
|
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func write(content: String, to url: URL) {
|
||||||
|
do {
|
||||||
|
try content.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("Backup write failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listBackups(in directory: URL, location: BackupLocation) -> [BackupRecord] {
|
||||||
|
guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.compactMap { url in
|
||||||
|
guard url.lastPathComponent.hasPrefix("backup-"),
|
||||||
|
url.pathExtension.lowercased() == "json" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = url.deletingPathExtension().lastPathComponent
|
||||||
|
let date = parseDate(from: name) ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date()
|
||||||
|
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map { Int64($0) } ?? 0
|
||||||
|
|
||||||
|
return BackupRecord(
|
||||||
|
id: "\(location.rawValue)-\(name)",
|
||||||
|
url: url,
|
||||||
|
date: date,
|
||||||
|
size: size,
|
||||||
|
location: location
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruneBackups(in directory: URL, keep: Int, location: BackupLocation) {
|
||||||
|
guard keep > 0 else { return }
|
||||||
|
let backups = listBackups(in: directory, location: location).sorted { $0.date > $1.date }
|
||||||
|
let toDelete = backups.dropFirst(keep)
|
||||||
|
for backup in toDelete {
|
||||||
|
try? fileManager.removeItem(at: backup.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseDate(from filename: String) -> Date? {
|
||||||
|
let parts = filename.split(separator: "-")
|
||||||
|
guard parts.count >= 3 else { return nil }
|
||||||
|
let dateString = "\(parts[1])-\(parts[2])"
|
||||||
|
return dateFormatter.date(from: dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] {
|
||||||
|
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)]
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||||
|
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ class SettingsViewModel: ObservableObject {
|
|||||||
@Published var showingExportOptions = false
|
@Published var showingExportOptions = false
|
||||||
@Published var showingImportSheet = false
|
@Published var showingImportSheet = false
|
||||||
@Published var showingResetConfirmation = false
|
@Published var showingResetConfirmation = false
|
||||||
|
@Published var isBackupInProgress = false
|
||||||
|
@Published var isRestoreInProgress = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var successMessage: String?
|
@Published var successMessage: String?
|
||||||
|
|
||||||
@@ -29,6 +31,12 @@ class SettingsViewModel: ObservableObject {
|
|||||||
@Published var totalSnapshots = 0
|
@Published var totalSnapshots = 0
|
||||||
@Published var totalCategories = 0
|
@Published var totalCategories = 0
|
||||||
|
|
||||||
|
// MARK: - Backups
|
||||||
|
|
||||||
|
@Published var backupRetentionCount = 10
|
||||||
|
@Published var backups: [BackupRecord] = []
|
||||||
|
@Published var backupsEnabled = false
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
private let iapService: IAPService
|
private let iapService: IAPService
|
||||||
@@ -37,6 +45,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
private let categoryRepository: CategoryRepository
|
private let categoryRepository: CategoryRepository
|
||||||
private let freemiumValidator: FreemiumValidator
|
private let freemiumValidator: FreemiumValidator
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let backupsEnabledKey = "backupsEnabled"
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -61,7 +70,10 @@ class SettingsViewModel: ObservableObject {
|
|||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
iapService.$isPremium
|
iapService.$isPremium
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$isPremium)
|
.sink { [weak self] isPremium in
|
||||||
|
self?.handlePremiumChange(isPremium)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
iapService.$isFamilyShared
|
iapService.$isFamilyShared
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@@ -82,6 +94,13 @@ class SettingsViewModel: ObservableObject {
|
|||||||
analyticsEnabled = settings.enableAnalytics
|
analyticsEnabled = settings.enableAnalytics
|
||||||
currencyCode = settings.currency
|
currencyCode = settings.currency
|
||||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||||
|
backupRetentionCount = loadBackupRetention()
|
||||||
|
backupsEnabled = loadBackupsEnabled()
|
||||||
|
if backupsEnabled {
|
||||||
|
refreshBackups()
|
||||||
|
} else {
|
||||||
|
backups = []
|
||||||
|
}
|
||||||
|
|
||||||
// Load statistics directly from database to avoid async race conditions
|
// Load statistics directly from database to avoid async race conditions
|
||||||
loadStatistics()
|
loadStatistics()
|
||||||
@@ -361,6 +380,85 @@ class SettingsViewModel: ObservableObject {
|
|||||||
NotificationCenter.default.post(name: .didResetData, object: nil)
|
NotificationCenter.default.post(name: .didResetData, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Backups
|
||||||
|
|
||||||
|
func updateBackupRetention(_ count: Int) {
|
||||||
|
guard backupsEnabled, isPremium else { return }
|
||||||
|
backupRetentionCount = count
|
||||||
|
UserDefaults.standard.set(count, forKey: "backupRetentionCount")
|
||||||
|
refreshBackups()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshBackups() {
|
||||||
|
guard backupsEnabled, isPremium else { return }
|
||||||
|
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
|
||||||
|
backups = BackupService.shared.listAllBackups(includeICloud: includeICloud)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBackupNow() {
|
||||||
|
guard backupsEnabled, isPremium else { return }
|
||||||
|
guard !isBackupInProgress else { return }
|
||||||
|
isBackupInProgress = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
|
||||||
|
let records = BackupService.shared.createBackup(
|
||||||
|
retentionCount: backupRetentionCount,
|
||||||
|
includeICloud: includeICloud
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
backups = records
|
||||||
|
isBackupInProgress = false
|
||||||
|
successMessage = "Backup saved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreBackup(_ backup: BackupRecord) {
|
||||||
|
guard backupsEnabled, isPremium else { return }
|
||||||
|
guard !isRestoreInProgress else { return }
|
||||||
|
isRestoreInProgress = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let content: String
|
||||||
|
do {
|
||||||
|
content = try String(contentsOf: backup.url, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isRestoreInProgress = false
|
||||||
|
errorMessage = "Failed to read backup file."
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
resetAllData()
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowMultiple = iapService.isPremium
|
||||||
|
let result = await ImportService.shared.importDataAsync(
|
||||||
|
content: content,
|
||||||
|
format: .json,
|
||||||
|
allowMultipleAccounts: allowMultiple,
|
||||||
|
defaultAccountName: Account.defaultAccountName,
|
||||||
|
progress: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
isRestoreInProgress = false
|
||||||
|
if result.errors.isEmpty {
|
||||||
|
successMessage = "Backup restored"
|
||||||
|
} else {
|
||||||
|
errorMessage = "Backup restored with warnings."
|
||||||
|
}
|
||||||
|
loadSettings()
|
||||||
|
refreshBackups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
var appVersion: String {
|
var appVersion: String {
|
||||||
@@ -405,4 +503,52 @@ class SettingsViewModel: ObservableObject {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadBackupRetention() -> Int {
|
||||||
|
let value = UserDefaults.standard.integer(forKey: "backupRetentionCount")
|
||||||
|
let options = [5, 10, 20]
|
||||||
|
return options.contains(value) ? value : 10
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadBackupsEnabled() -> Bool {
|
||||||
|
let enabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
|
||||||
|
if !isPremium && enabled {
|
||||||
|
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isPremium && enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePremiumChange(_ isPremium: Bool) {
|
||||||
|
self.isPremium = isPremium
|
||||||
|
if isPremium {
|
||||||
|
backupsEnabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
|
||||||
|
if backupsEnabled {
|
||||||
|
refreshBackups()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if backupsEnabled {
|
||||||
|
backupsEnabled = false
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||||
|
backups = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBackupsEnabled(_ enabled: Bool) {
|
||||||
|
guard isPremium else {
|
||||||
|
backupsEnabled = false
|
||||||
|
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||||
|
showingPaywall = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backupsEnabled = enabled
|
||||||
|
UserDefaults.standard.set(enabled, forKey: backupsEnabledKey)
|
||||||
|
if enabled {
|
||||||
|
refreshBackups()
|
||||||
|
} else {
|
||||||
|
backups = []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct SettingsView: View {
|
|||||||
@State private var showingPinDisableAlert = false
|
@State private var showingPinDisableAlert = false
|
||||||
@State private var showingRestartAlert = false
|
@State private var showingRestartAlert = false
|
||||||
@State private var didLoadCloudSync = false
|
@State private var didLoadCloudSync = false
|
||||||
|
@State private var backupToRestore: BackupRecord?
|
||||||
|
|
||||||
init(iapService: IAPService) {
|
init(iapService: IAPService) {
|
||||||
self.iapService = iapService
|
self.iapService = iapService
|
||||||
@@ -41,6 +42,9 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// Data Section
|
// Data Section
|
||||||
dataSection
|
dataSection
|
||||||
|
if viewModel.backupsEnabled {
|
||||||
|
backupsSection
|
||||||
|
}
|
||||||
|
|
||||||
// Security Section
|
// Security Section
|
||||||
securitySection
|
securitySection
|
||||||
@@ -86,6 +90,26 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("This will permanently delete all your investment data. This action cannot be undone.")
|
Text("This will permanently delete all your investment data. This action cannot be undone.")
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Restore Backup",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { backupToRestore != nil },
|
||||||
|
set: { if !$0 { backupToRestore = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Restore", role: .destructive) {
|
||||||
|
if let backup = backupToRestore {
|
||||||
|
viewModel.restoreBackup(backup)
|
||||||
|
backupToRestore = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
backupToRestore = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("This will replace your current data with the selected backup.")
|
||||||
|
}
|
||||||
.alert("Success", isPresented: .constant(viewModel.successMessage != nil)) {
|
.alert("Success", isPresented: .constant(viewModel.successMessage != nil)) {
|
||||||
Button("OK") {
|
Button("OK") {
|
||||||
viewModel.successMessage = nil
|
viewModel.successMessage = nil
|
||||||
@@ -141,6 +165,9 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
didLoadCloudSync = true
|
didLoadCloudSync = true
|
||||||
|
if viewModel.backupsEnabled {
|
||||||
|
viewModel.refreshBackups()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,8 +330,25 @@ struct SettingsView: View {
|
|||||||
if didLoadCloudSync {
|
if didLoadCloudSync {
|
||||||
showingRestartAlert = true
|
showingRestartAlert = true
|
||||||
}
|
}
|
||||||
|
viewModel.refreshBackups()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toggle(
|
||||||
|
isOn: Binding(
|
||||||
|
get: { viewModel.backupsEnabled },
|
||||||
|
set: { viewModel.setBackupsEnabled($0) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Enable Backups")
|
||||||
|
if !viewModel.isPremium {
|
||||||
|
Text("Premium")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.appWarning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if viewModel.canExport {
|
if viewModel.canExport {
|
||||||
viewModel.showingExportOptions = true
|
viewModel.showingExportOptions = true
|
||||||
@@ -359,6 +403,66 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Backups Section
|
||||||
|
|
||||||
|
private var backupsSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Keep Backups", selection: $viewModel.backupRetentionCount) {
|
||||||
|
Text("5").tag(5)
|
||||||
|
Text("10").tag(10)
|
||||||
|
Text("20").tag(20)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.backupRetentionCount) { _, newValue in
|
||||||
|
viewModel.updateBackupRetention(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.createBackupNow()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label("Create Backup Now", systemImage: "arrow.clockwise")
|
||||||
|
Spacer()
|
||||||
|
if viewModel.isBackupInProgress {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isBackupInProgress || viewModel.isRestoreInProgress)
|
||||||
|
|
||||||
|
if viewModel.backups.isEmpty {
|
||||||
|
Text("No backups yet.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.backups) { backup in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(backup.date.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.subheadline)
|
||||||
|
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Restore") {
|
||||||
|
backupToRestore = backup
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isRestoreInProgress)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button("Share Backup") {
|
||||||
|
viewModel.shareItem = SettingsViewModel.ShareItem(url: backup.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Backups")
|
||||||
|
} footer: {
|
||||||
|
Text("Backups are stored locally and in iCloud (when enabled).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Security Section
|
// MARK: - Security Section
|
||||||
|
|
||||||
private var securitySection: some View {
|
private var securitySection: some View {
|
||||||
@@ -589,6 +693,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
return "Portfolio Journal"
|
return "Portfolio Journal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func formatBytes(_ bytes: Int64) -> String {
|
||||||
|
let formatter = ByteCountFormatter()
|
||||||
|
formatter.countStyle = .file
|
||||||
|
return formatter.string(fromByteCount: bytes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Activity View
|
// MARK: - Activity View
|
||||||
|
|||||||
Reference in New Issue
Block a user