diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f81edb940..f71dff4c5 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/synonymdev/bitkit-core", "state" : { "branch" : "master", - "revision" : "1a714203e9780d0d5c53a2e8fccd1e6a5b05716c" + "revision" : "3cd496c25f94dcc81401f8b46e3fbe65b139eb02" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 19b534720..3c7e9d9ef 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1,3 +1,4 @@ +import Combine import LDKNode import SwiftUI @@ -17,7 +18,7 @@ struct AppScene: View { @StateObject private var widgets = WidgetsViewModel() @StateObject private var pushManager = PushNotificationManager.shared @StateObject private var scannerManager = ScannerManager() - @StateObject private var settings = SettingsViewModel() + @StateObject private var settings = SettingsViewModel.shared @StateObject private var suggestionsManager = SuggestionsManager() @StateObject private var tagManager = TagManager() @StateObject private var transferTracking: TransferTrackingManager @@ -51,7 +52,7 @@ struct AppScene: View { _activity = StateObject(wrappedValue: ActivityListViewModel(transferService: transferService)) _transfer = StateObject(wrappedValue: TransferViewModel(transferService: transferService)) _widgets = StateObject(wrappedValue: WidgetsViewModel()) - _settings = StateObject(wrappedValue: SettingsViewModel()) + _settings = StateObject(wrappedValue: SettingsViewModel.shared) _transferTracking = StateObject(wrappedValue: TransferTrackingManager(service: transferService)) } @@ -100,15 +101,9 @@ struct AppScene: View { ) { notification in handleQuickAction(notification) } - - // Listen for backup failure notifications - NotificationCenter.default.addObserver( - forName: .backupFailureNotification, - object: nil, - queue: .main - ) { notification in - handleBackupFailure(notification) - } + } + .onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in + handleBackupFailure(intervalMinutes: intervalMinutes) } } @@ -274,10 +269,10 @@ struct AppScene: View { } else if state == .running { walletInitShouldFinish = true BackupService.shared.startObservingBackups() - } else if case .errorStarting = state { - walletInitShouldFinish = true - BackupService.shared.stopObservingBackups() } else { + if case .errorStarting = state { + walletInitShouldFinish = true + } BackupService.shared.stopObservingBackups() } } @@ -309,12 +304,11 @@ struct AppScene: View { } } - private func handleBackupFailure(_ notification: Notification) { - let interval = notification.userInfo?["interval"] as? Int ?? 1 + private func handleBackupFailure(intervalMinutes: Int) { app.toast( type: .error, title: t("settings__backup__failed_title"), - description: t("settings__backup__failed_message", variables: ["interval": "\(interval)"]) + description: t("settings__backup__failed_message", variables: ["interval": "\(intervalMinutes)"]) ) } } diff --git a/Bitkit/Components/Home/Suggestions.swift b/Bitkit/Components/Home/Suggestions.swift index 77ef3f992..be6f7e6f9 100644 --- a/Bitkit/Components/Home/Suggestions.swift +++ b/Bitkit/Components/Home/Suggestions.swift @@ -266,6 +266,6 @@ struct Suggestions: View { Suggestions() } .environmentObject(SheetViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .preferredColorScheme(.dark) } diff --git a/Bitkit/Components/MoneyCell.swift b/Bitkit/Components/MoneyCell.swift index 4856063b9..a42b7e34c 100644 --- a/Bitkit/Components/MoneyCell.swift +++ b/Bitkit/Components/MoneyCell.swift @@ -42,7 +42,7 @@ private extension MoneyCell { } static func previewSettingsVM(hideBalance: Bool = false) -> SettingsViewModel { - let vm = SettingsViewModel() + let vm = SettingsViewModel.shared vm.hideBalance = hideBalance return vm } diff --git a/Bitkit/Components/MoneyStack.swift b/Bitkit/Components/MoneyStack.swift index 5bd3a4b68..8ab01c62a 100644 --- a/Bitkit/Components/MoneyStack.swift +++ b/Bitkit/Components/MoneyStack.swift @@ -188,7 +188,7 @@ private extension MoneyStack { } static func previewSettingsVM(hideBalance: Bool = false) -> SettingsViewModel { - let vm = SettingsViewModel() + let vm = SettingsViewModel.shared vm.hideBalance = hideBalance return vm } diff --git a/Bitkit/Components/MoneyText.swift b/Bitkit/Components/MoneyText.swift index 1f5aa92f5..62b4a2ef3 100644 --- a/Bitkit/Components/MoneyText.swift +++ b/Bitkit/Components/MoneyText.swift @@ -184,7 +184,7 @@ private extension MoneyText { } static func previewSettingsVM(hideBalance: Bool = false) -> SettingsViewModel { - let vm = SettingsViewModel() + let vm = SettingsViewModel.shared vm.hideBalance = hideBalance return vm } diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index d71a648ca..511df7cbd 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -262,6 +262,6 @@ struct WidgetButtonStyle: ButtonStyle { .environmentObject(WidgetsViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(CurrencyViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .preferredColorScheme(.dark) } diff --git a/Bitkit/Models/BackupCategory.swift b/Bitkit/Models/BackupCategory.swift index 2fa467558..bdcc84146 100644 --- a/Bitkit/Models/BackupCategory.swift +++ b/Bitkit/Models/BackupCategory.swift @@ -3,12 +3,11 @@ import Foundation enum BackupCategory: String, CaseIterable { case lightningConnections = "LIGHTNING_CONNECTIONS" case blocktank = "BLOCKTANK" - case ldkActivity = "LDK_ACTIVITY" + case activity = "ACTIVITY" case wallet = "WALLET" case settings = "SETTINGS" case widgets = "WIDGETS" case metadata = "METADATA" - case slashtags = "SLASHTAGS" } // MARK: - UI Extensions @@ -20,7 +19,7 @@ extension BackupCategory { return "bolt-hollow" case .blocktank: return "note" - case .ldkActivity: + case .activity: return "transfer" case .wallet: return "timer-alt" @@ -30,8 +29,6 @@ extension BackupCategory { return "stack" case .metadata: return "tag" - case .slashtags: - return "users" } } @@ -41,7 +38,7 @@ extension BackupCategory { return t("settings__backup__category_connections") case .blocktank: return t("settings__backup__category_connection_receipts") - case .ldkActivity: + case .activity: return t("settings__backup__category_transaction_log") case .wallet: return t("settings__backup__category_wallet") @@ -51,8 +48,6 @@ extension BackupCategory { return t("settings__backup__category_widgets") case .metadata: return t("settings__backup__category_tags") - case .slashtags: - return t("settings__backup__category_contacts") } } } diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift new file mode 100644 index 000000000..5393f2fad --- /dev/null +++ b/Bitkit/Models/BackupPayloads.swift @@ -0,0 +1,61 @@ +import BitkitCore +import Foundation + +// MARK: - Backup Payload Models + +struct WalletBackupV1: Codable { + let version: Int + let createdAt: UInt64 + let transfers: [Transfer] +} + +struct MetadataBackupV1: Codable { + let version: Int + let createdAt: UInt64 + let tagMetadata: [TagMetadataItem] + let cache: AppCacheData +} + +struct TagMetadataItem: Codable { + let id: String + let paymentHash: String? + let txId: String? + let address: String + let isReceive: Bool + let tags: [String] + let createdAt: UInt64 +} + +struct AppCacheData: Codable { + let hasSeenContactsIntro: Bool + let hasSeenProfileIntro: Bool + let hasSeenNotificationsIntro: Bool + let hasSeenQuickpayIntro: Bool + let hasSeenShopIntro: Bool + let hasSeenTransferIntro: Bool + let hasSeenTransferToSpendingIntro: Bool + let hasSeenTransferToSavingsIntro: Bool + let hasSeenWidgetsIntro: Bool + let showHomeViewEmptyState: Bool + let appUpdateIgnoreTimestamp: TimeInterval + let backupIgnoreTimestamp: TimeInterval + let highBalanceIgnoreCount: Int + let highBalanceIgnoreTimestamp: TimeInterval + let dismissedSuggestions: [String] + let lastUsedTags: [String] +} + +struct BlocktankBackupV1: Codable { + let version: Int + let createdAt: UInt64 + let orders: [IBtOrder] + let cjitEntries: [IcJitEntry] + let info: IBtInfo? +} + +struct ActivityBackupV1: Codable { + let version: Int + let createdAt: UInt64 + let activities: [Activity] + let closedChannels: [ClosedChannelDetails] +} diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift new file mode 100644 index 000000000..248ebcf93 --- /dev/null +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Configuration for settings backup/restore operations +enum SettingsBackupConfig { + enum SettingKeyType { + case string(optional: Bool) + case bool + case double(optional: Bool, minValue: Double = 0) + case int(optional: Bool, minValue: Int = 0) + case stringArray(optional: Bool) + } + + static let serverSettingsKeys: [String] = [ + "electrumServer", + "rapidGossipSyncUrl", + ] + + static let appStateKeys: [String] = [ + "hasSeenContactsIntro", + "hasSeenProfileIntro", + "hasSeenNotificationsIntro", + "hasSeenQuickpayIntro", + "hasSeenShopIntro", + "hasSeenTransferIntro", + "hasSeenTransferToSpendingIntro", + "hasSeenTransferToSavingsIntro", + "hasSeenWidgetsIntro", + "showHomeViewEmptyState", + "appUpdateIgnoreTimestamp", + "backupIgnoreTimestamp", + "highBalanceIgnoreCount", + "highBalanceIgnoreTimestamp", + "dismissedSuggestions", + "lastUsedTags", + ] + + static let settingsKeyTypes: [String: SettingKeyType] = [ + "primaryDisplay": .string(optional: true), + "bitcoinDisplayUnit": .string(optional: true), + "selectedCurrency": .string(optional: true), + "defaultTransactionSpeed": .string(optional: true), + "coinSelectionMethod": .string(optional: true), + "coinSelectionAlgorithm": .string(optional: true), + "enableQuickpay": .bool, + "showWidgets": .bool, + "showWidgetTitles": .bool, + "swipeBalanceToHide": .bool, + "hideBalance": .bool, + "hideBalanceOnOpen": .bool, + "readClipboard": .bool, + "warnWhenSendingOver100": .bool, + "backupVerified": .bool, + "enableNotifications": .bool, + "quickpayAmount": .double(optional: false), + ] + + static var settingsKeys: [String] { + Array(settingsKeyTypes.keys) + serverSettingsKeys + } + + static let iosToAndroidFieldMapping: [String: String] = [ + "readClipboard": "enableAutoReadClipboard", + "swipeBalanceToHide": "enableSwipeToHideBalance", + "warnWhenSendingOver100": "enableSendAmountWarning", + "bitcoinDisplayUnit": "displayUnit", + "enableQuickpay": "isQuickPayEnabled", + "useBiometrics": "isBiometricEnabled", + "requirePinForPayments": "isPinForPaymentsEnabled", + "enableNotifications": "notificationsGranted", + ] + + static let algorithmMapping: [String: String] = [ + "branchAndBound": "BranchAndBound", + "largestFirst": "LargestFirst", + "oldestFirst": "FirstInFirstOut", + "singleRandomDraw": "SingleRandomDraw", + ] + + static func convertAlgorithm(_ value: String, toAndroid: Bool) -> String { + if toAndroid { + return algorithmMapping[value] ?? "BranchAndBound" + } else { + // Reverse lookup + for (ios, android) in algorithmMapping where android == value { + return ios + } + return "largestFirst" + } + } +} diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 4454c17aa..b7660eacc 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -1,13 +1,8 @@ +import BitkitCore import Combine import Foundation import VssRustClientFfi -// MARK: - Notification Names - -extension Notification.Name { - static let backupFailureNotification = Notification.Name("backupFailureNotification") -} - // MARK: - BackupService class BackupService { @@ -20,7 +15,6 @@ class BackupService { private var periodicCheckTask: Task? private var isObserving = false private var isRestoring = false - private var lastNotificationTime: UInt64 = 0 private let defaults = UserDefaults.standard @@ -29,7 +23,12 @@ class BackupService { private let statusUpdateQueue = DispatchQueue(label: "backup-service-status-update", qos: .userInitiated) private let backupStatusesSubject = PassthroughSubject<[BackupCategory: BackupItemStatus], Never>() - /// Publisher that emits when backup statuses change + private let backupFailureSubject = PassthroughSubject() + + var backupFailurePublisher: AnyPublisher { + backupFailureSubject.eraseToAnyPublisher() + } + var backupStatusesPublisher: AnyPublisher<[BackupCategory: BackupItemStatus], Never> { backupStatusesSubject .removeDuplicates { old, new in @@ -53,46 +52,48 @@ class BackupService { // MARK: - Public Methods func startObservingBackups() { - guard !isObserving else { return } + Task { + let shouldStart = try? await ServiceQueue.background(.backup) { + guard !self.isObserving else { return false } + self.isObserving = true + return true + } - isObserving = true - Logger.debug("Start observing backup statuses and data store changes", context: "BackupService") + guard shouldStart == true else { return } - Task { try? await vssBackupClient.setup() + startBackupStatusObservers() + startDataStoreListeners() + startPeriodicBackupFailureCheck() } - - startBackupStatusObservers() - startDataStoreListeners() - startPeriodicBackupFailureCheck() } func stopObservingBackups() { - guard isObserving else { return } - - isObserving = false - - backupJobs.values.forEach { $0.cancel() } - backupJobs.removeAll() - runningBackupTasks.values.forEach { $0.cancel() } - runningBackupTasks.removeAll() - periodicCheckTask?.cancel() - periodicCheckTask = nil - cancellables.removeAll() + Task { + let shouldStop = try? await ServiceQueue.background(.backup) { + guard self.isObserving else { return false } + self.isObserving = false + + self.backupJobs.values.forEach { $0.cancel() } + self.backupJobs.removeAll() + self.runningBackupTasks.values.forEach { $0.cancel() } + self.runningBackupTasks.removeAll() + self.periodicCheckTask?.cancel() + self.periodicCheckTask = nil + return true + } - Logger.debug("Stopped observing backup statuses and data store changes", context: "BackupService") + guard shouldStop == true else { return } + cancellables.removeAll() + } } func triggerBackup(category: BackupCategory) async { - // Check if backup is already running for this category - if let existingTask = runningBackupTasks[category], !existingTask.isCancelled { - Logger.debug("Backup already running for: '\(category.rawValue)', skipping duplicate trigger", context: "BackupService") + let existingTask = try? await ServiceQueue.background(.backup) { self.runningBackupTasks[category] } + if let existingTask, !existingTask.isCancelled { return } - Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService") - - // Track the running backup task let backupTask = Task { updateBackupStatus(category: category) { status in BackupItemStatus( @@ -118,7 +119,6 @@ class BackupService { Logger.info("Backup succeeded for: '\(category.rawValue)'", context: "BackupService") } catch let error as CancellationError { - // If backup was cancelled, don't retry - clear the required timestamp to prevent retry loop updateBackupStatus(category: category) { status in BackupItemStatus( synced: status.synced, @@ -126,7 +126,6 @@ class BackupService { running: false ) } - Logger.debug("Backup cancelled for: '\(category.rawValue)'", context: "BackupService") } catch { updateBackupStatus(category: category) { status in BackupItemStatus( @@ -138,27 +137,24 @@ class BackupService { Logger.error("Backup failed for: '\(category.rawValue)': \(error)", context: "BackupService") } - // Remove from running tasks when done - runningBackupTasks.removeValue(forKey: category) + try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) } } - runningBackupTasks[category] = backupTask + try? await ServiceQueue.background(.backup) { self.runningBackupTasks[category] = backupTask } await backupTask.value } /// Performs full restore from latest backup func performFullRestoreFromLatestBackup() async { - Logger.debug("Full restore starting", context: "BackupService") - - isRestoring = true - defer { isRestoring = false } + try? await ServiceQueue.background(.backup) { self.isRestoring = true } do { try await performRestore(category: .settings) { dataBytes in guard let settingsDict = try JSONSerialization.jsonObject(with: dataBytes) as? [String: Any] else { throw NSError(domain: "BackupService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse settings JSON"]) } - SettingsStore.shared.restoreSettingsDictionary(settingsDict) + + await SettingsViewModel.shared.restoreSettingsDictionary(settingsDict) Logger.info("Settings restored successfully", context: "BackupService") } @@ -173,10 +169,69 @@ class BackupService { Logger.info("Widgets restored successfully, count: \(decodedWidgets.count)", context: "BackupService") } + try await performRestore(category: .wallet) { dataBytes in + let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes) + try TransferStorage.shared.upsertList(payload.transfers) + + Logger.info("Restored \(payload.transfers.count) transfers", context: "BackupService") + } + + try await performRestore(category: .metadata) { dataBytes in + let payload = try JSONDecoder().decode(MetadataBackupV1.self, from: dataBytes) + + for tagMetadata in payload.tagMetadata { + do { + let existingTags = try await CoreService.shared.activity.tags(forActivity: tagMetadata.id) + if !existingTags.isEmpty { + try await CoreService.shared.activity.dropTags(fromActivity: tagMetadata.id, existingTags) + } + if !tagMetadata.tags.isEmpty { + try await CoreService.shared.activity.appendTags(toActivity: tagMetadata.id, tagMetadata.tags) + } + } catch { + Logger.warn("Failed to restore tags for activity \(tagMetadata.id): \(error)", context: "BackupService") + } + } + + await SettingsViewModel.shared.restoreAppCacheData(payload.cache) + + Logger.info("Restored app state and \(payload.tagMetadata.count) tags metadata", context: "BackupService") + } + + try await performRestore(category: .blocktank) { dataBytes in + let payload = try JSONDecoder().decode(BlocktankBackupV1.self, from: dataBytes) + + try await CoreService.shared.blocktank.upsertOrdersList(payload.orders) + try await CoreService.shared.blocktank.upsertCjitEntriesList(payload.cjitEntries) + + if let info = payload.info { + try await CoreService.shared.blocktank.setInfo(info) + } + + Logger.info( + "Restored \(payload.orders.count) orders, \(payload.cjitEntries.count) CJIT entries\(payload.info != nil ? ", with info" : "")", + context: "BackupService" + ) + } + + try await performRestore(category: .activity) { dataBytes in + let payload = try JSONDecoder().decode(ActivityBackupV1.self, from: dataBytes) + + try await CoreService.shared.activity.upsertList(payload.activities) + try await CoreService.shared.activity.upsertClosedChannelList(payload.closedChannels) + + Logger.info( + "Restored \(payload.activities.count) activities, \(payload.closedChannels.count) closed channels", + context: "BackupService" + ) + } + Logger.info("Full restore completed", context: "BackupService") } catch { Logger.warn("Full restore error: \(error)", context: "BackupService") } + + try? await ServiceQueue.background(.backup) { self.isRestoring = false } } // MARK: - Private Methods @@ -196,90 +251,138 @@ class BackupService { distinctPublisher .dropFirst() .sink { [weak self] status in - guard let self, !self.isRestoring else { return } - - if status.synced < status.required && !status.running { - scheduleBackup(category: category) + guard let self else { return } + Task { + let isRestoring = try? await ServiceQueue.background(.backup) { self.isRestoring } + guard isRestoring != true else { return } + + if status.synced < status.required && !status.running { + await self.scheduleBackup(category: category) + } } } .store(in: &cancellables) } - - Logger.debug("Started \(BackupCategory.allCases.count) reactive backup status observers", context: "BackupService") } private func startDataStoreListeners() { - SettingsStore.shared.settingsPublisher + // SETTINGS + SettingsViewModel.shared.settingsPublisher .sink { [weak self] _ in guard let self, !self.isRestoring else { return } markBackupRequired(category: .settings) } .store(in: &cancellables) - SettingsStore.shared.widgetsPublisher + // WIDGETS + SettingsViewModel.shared.widgetsPublisher .sink { [weak self] _ in guard let self, !self.isRestoring else { return } markBackupRequired(category: .widgets) } .store(in: &cancellables) - Logger.debug("Started 2 data store listeners", context: "BackupService") + // TRANSFERS + TransferStorage.shared.transfersChangedPublisher + .sink { [weak self] _ in + guard let self, !self.isRestoring else { return } + markBackupRequired(category: .wallet) + } + .store(in: &cancellables) + + // ACTIVITIES (triggers both metadata and activity backups) + CoreService.shared.activity.activitiesChangedPublisher + .sink { [weak self] _ in + guard let self, !self.isRestoring else { return } + markBackupRequired(category: .metadata) + markBackupRequired(category: .activity) + } + .store(in: &cancellables) + + SettingsViewModel.shared.appStatePublisher + .sink { [weak self] _ in + guard let self, !self.isRestoring else { return } + markBackupRequired(category: .metadata) + } + .store(in: &cancellables) + + // BLOCKTANK + CoreService.shared.blocktank.stateChangedPublisher + .sink { [weak self] _ in + guard let self, !self.isRestoring else { return } + markBackupRequired(category: .blocktank) + } + .store(in: &cancellables) + + // LIGHTNING SYNC STATUS + LightningService.shared.syncStatusChangedPublisher + .sink { [weak self] lastSync in + guard let self, !self.isRestoring else { return } + updateBackupStatus(category: .lightningConnections) { _ in + BackupItemStatus( + synced: lastSync, + required: lastSync, + running: false + ) + } + } + .store(in: &cancellables) } private func startPeriodicBackupFailureCheck() { - periodicCheckTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(Self.backupFailureCheckInterval * 1_000_000_000)) - guard !Task.isCancelled else { break } - self?.checkForFailedBackups() + Task { + try? await ServiceQueue.background(.backup) { + self.periodicCheckTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(Self.backupFailureCheckInterval * 1_000_000_000)) + guard !Task.isCancelled else { break } + self.checkForFailedBackups() + } + } } } } private func markBackupRequired(category: BackupCategory) { updateBackupStatus(category: category) { status in - // Always update required timestamp to current time when data changes - // Even if backup is running, we want to track that new changes came in BackupItemStatus( synced: status.synced, required: UInt64(Date().timeIntervalSince1970), running: status.running ) } - Logger.debug("Marked backup required for: '\(category.rawValue)'", context: "BackupService") - // Immediately check if backup should be scheduled (in case this is the first time) - // The reactive observer will also handle this, but this ensures immediate action - let status = getBackupStatus(category: category) - if status.synced < status.required && !status.running && !isRestoring { - scheduleBackup(category: category) + Task { + let status = getBackupStatus(category: category) + let isCurrentlyRestoring = try? await ServiceQueue.background(.backup) { self.isRestoring } + if status.synced < status.required && !status.running && isCurrentlyRestoring != true { + await scheduleBackup(category: category) + } } } - private func scheduleBackup(category: BackupCategory) { - // Check if backup is already running - if so, don't reschedule + private func scheduleBackup(category: BackupCategory) async { let currentStatus = getBackupStatus(category: category) if currentStatus.running { - Logger.debug("Backup already running for: '\(category.rawValue)', skipping reschedule", context: "BackupService") return } - // Cancel existing scheduled backup job for this category (if any) - backupJobs[category]?.cancel() - - Logger.debug("Scheduling backup for: '\(category.rawValue)'", context: "BackupService") - let backupTask = Task { try? await Task.sleep(nanoseconds: UInt64(Self.backupDebounce * 1_000_000_000)) - // Double-check if backup is still needed and not already running + guard !Task.isCancelled else { return } + let status = getBackupStatus(category: category) - if status.synced < status.required && !status.running && !isRestoring { + let isCurrentlyRestoring = try? await ServiceQueue.background(.backup) { self.isRestoring } + if status.synced < status.required && !status.running && isCurrentlyRestoring != true { await triggerBackup(category: category) } } - backupJobs[category] = backupTask + try? await ServiceQueue.background(.backup) { + self.backupJobs[category]?.cancel() + self.backupJobs[category] = backupTask + } } private func checkForFailedBackups() { @@ -297,21 +400,20 @@ class BackupService { } private func showBackupFailureNotification(currentTime: UInt64) { - // Throttle notifications - if currentTime - lastNotificationTime < Self.failedBackupNotificationInterval { - return - } + Task { + try? await ServiceQueue.background(.backup) { + if currentTime - self.lastNotificationTime < Self.failedBackupNotificationInterval { + return + } - lastNotificationTime = currentTime + self.lastNotificationTime = currentTime - let backupCheckIntervalMinutes = Int(Self.backupFailureCheckInterval / 60) - NotificationCenter.default.post( - name: .backupFailureNotification, - object: nil, - userInfo: ["interval": backupCheckIntervalMinutes] - ) + let backupCheckIntervalMinutes = Int(Self.backupFailureCheckInterval / 60) + self.backupFailureSubject.send(backupCheckIntervalMinutes) - Logger.warn("Backup failed for more than 30 minutes", context: "BackupService") + Logger.warn("Backup failed for more than 30 minutes", context: "BackupService") + } + } } func getBackupStatus(category: BackupCategory) -> BackupItemStatus { @@ -320,9 +422,7 @@ class BackupService { } private func updateBackupStatus(category: BackupCategory, update: @escaping (BackupItemStatus) -> BackupItemStatus) { - statusUpdateQueue.sync { [weak self] in - guard let self else { return } - + statusUpdateQueue.sync { var statuses = getAllBackupStatuses() let currentStatus = statuses[category] ?? BackupItemStatus() statuses[category] = update(currentStatus) @@ -365,7 +465,7 @@ class BackupService { private func getBackupDataBytes(category: BackupCategory) async throws -> Data { switch category { case .settings: - let settingsDict = SettingsStore.shared.getSettingsDictionary() + let settingsDict = await SettingsViewModel.shared.getSettingsDictionary() return try JSONSerialization.data(withJSONObject: settingsDict, options: []) case .widgets: @@ -381,8 +481,106 @@ class BackupService { return widgetsData } - case .wallet, .metadata, .blocktank, .slashtags, .ldkActivity, .lightningConnections: - throw NSError(domain: "BackupService", code: -1, userInfo: [NSLocalizedDescriptionKey: "\(category.rawValue) backup not yet implemented"]) + case .wallet: + let transfers = try TransferStorage.shared.getAll() + let payload = WalletBackupV1( + version: 1, + createdAt: UInt64(Date().timeIntervalSince1970), + transfers: transfers + ) + return try JSONEncoder().encode(payload) + + case .metadata: + let currentTime = UInt64(Date().timeIntervalSince1970) + + var tagMetadata: [TagMetadataItem] = [] + do { + let activities = try await CoreService.shared.activity.get() + for activity in activities { + let activityId = switch activity { + case let .lightning(ln): ln.id + case let .onchain(on): on.id + } + + let tags = try await CoreService.shared.activity.tags(forActivity: activityId) + guard !tags.isEmpty else { continue } + + let paymentHash: String? + let txId: String? + let address: String + let isReceive: Bool + + switch activity { + case let .lightning(ln): + paymentHash = ln.id + txId = nil + address = "" + isReceive = ln.txType == .received + case let .onchain(on): + paymentHash = nil + txId = on.id + address = on.address.isEmpty ? "" : on.address + isReceive = on.txType == .received + } + + tagMetadata.append(TagMetadataItem( + id: activityId, + paymentHash: paymentHash, + txId: txId, + address: address, + isReceive: isReceive, + tags: tags, + createdAt: currentTime + )) + } + } catch { + Logger.warn("Failed to get activities for metadata backup: \(error)", context: "BackupService") + } + + let cache = await SettingsViewModel.shared.getAppCacheData() + + let payload = MetadataBackupV1( + version: 1, + createdAt: currentTime, + tagMetadata: tagMetadata, + cache: cache + ) + return try JSONEncoder().encode(payload) + + case .blocktank: + let orders = try await CoreService.shared.blocktank.orders() + let cjitEntries = try await CoreService.shared.blocktank.cjitOrders() + let info = try? await CoreService.shared.blocktank.info(refresh: false) + + let payload = BlocktankBackupV1( + version: 1, + createdAt: UInt64(Date().timeIntervalSince1970), + orders: orders, + cjitEntries: cjitEntries, + info: info + ) + + return try JSONEncoder().encode(payload) + + case .activity: + let activities = try await CoreService.shared.activity.get() + let closedChannels = try await CoreService.shared.activity.closedChannels() + + let payload = ActivityBackupV1( + version: 1, + createdAt: UInt64(Date().timeIntervalSince1970), + activities: activities, + closedChannels: closedChannels + ) + + return try JSONEncoder().encode(payload) + + case .lightningConnections: + throw NSError( + domain: "BackupService", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "LIGHTNING_CONNECTIONS backup is managed by ldk-node"] + ) } } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 95d9f19ed..afdf695d6 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1,4 +1,5 @@ import BitkitCore +import Combine import Foundation import LDKNode @@ -7,6 +8,12 @@ import LDKNode class ActivityService { private let coreService: CoreService + private let activitiesChangedSubject = PassthroughSubject() + + var activitiesChangedPublisher: AnyPublisher { + activitiesChangedSubject.eraseToAnyPublisher() + } + // Track replacement transactions (RBF): newTxId -> parent/original txIds private static var replacementTransactions: [String: [String]] = [:] @@ -36,12 +43,33 @@ class ActivityService { _ = try deleteActivityById(activityId: id) } + + self.activitiesChangedSubject.send() } } func insert(_ activity: Activity) async throws { try await ServiceQueue.background(.core) { try insertActivity(activity: activity) + self.activitiesChangedSubject.send() + } + } + + func upsertList(_ activities: [Activity]) async throws { + try await ServiceQueue.background(.core) { + try upsertActivities(activities: activities) + } + } + + func closedChannels(sortDirection: SortDirection = .asc) async throws -> [ClosedChannelDetails] { + try await ServiceQueue.background(.core) { + try getAllClosedChannels(sortDirection: sortDirection) + } + } + + func upsertClosedChannelList(_ closedChannels: [ClosedChannelDetails]) async throws { + try await ServiceQueue.background(.core) { + try upsertClosedChannels(channels: closedChannels) } } @@ -197,6 +225,7 @@ class ActivityService { } Logger.info("Synced LDK payments - Added: \(addedCount) - Updated: \(updatedCount)", context: "CoreService") + self.activitiesChangedSubject.send() } } @@ -233,12 +262,15 @@ class ActivityService { func update(id: String, activity: Activity) async throws { try await ServiceQueue.background(.core) { try updateActivity(activityId: id, activity: activity) + self.activitiesChangedSubject.send() } } func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { - try deleteActivityById(activityId: id) + let result = try deleteActivityById(activityId: id) + self.activitiesChangedSubject.send() + return result } } @@ -247,12 +279,14 @@ class ActivityService { func appendTags(toActivity id: String, _ tags: [String]) async throws { try await ServiceQueue.background(.core) { try addTags(activityId: id, tags: tags) + self.activitiesChangedSubject.send() } } func dropTags(fromActivity id: String, _ tags: [String]) async throws { try await ServiceQueue.background(.core) { try removeTags(activityId: id, tags: tags) + self.activitiesChangedSubject.send() } } @@ -299,6 +333,7 @@ class ActivityService { onchainActivity.boostTxIds.append(txid) try updateActivity(activityId: activityId, activity: .onchain(onchainActivity)) Logger.info("Successfully marked activity \(activityId) as boosted via CPFP", context: "CoreService.boostOnchainTransaction") + self.activitiesChangedSubject.send() } else { Logger.info("Executing RBF boost for outgoing transaction", context: "CoreService.boostOnchainTransaction") Logger.debug("Original transaction ID: \(onchainActivity.txId)", context: "CoreService.boostOnchainTransaction") @@ -345,6 +380,8 @@ class ActivityService { "Warning: Original activity \(activityId) still exists after deletion attempt", context: "CoreService.boostOnchainTransaction" ) } + + self.activitiesChangedSubject.send() } return txid @@ -419,6 +456,7 @@ class ActivityService { } Logger.info("Generated \(activityId) test activities across all time periods", context: "CoreService") + self.activitiesChangedSubject.send() } } } @@ -576,6 +614,12 @@ private func generateTestDataSets() -> [(String, UInt64, [ActivityTemplate])] { class BlocktankService { private let coreService: CoreService + private let stateChangedSubject = PassthroughSubject() + + var stateChangedPublisher: AnyPublisher { + stateChangedSubject.eraseToAnyPublisher() + } + init(coreService: CoreService) { self.coreService = coreService } @@ -601,7 +645,7 @@ class BlocktankService { Logger.info("Creating CJIT invoice with channel size: \(channelSizeSat) and invoice amount: \(invoiceSat)", context: "BlocktankService") return try await ServiceQueue.background(.core) { - try await createCjitEntry( + let entry = try await createCjitEntry( channelSizeSat: channelSizeSat, invoiceSat: invoiceSat, invoiceDescription: invoiceDescription, @@ -609,6 +653,8 @@ class BlocktankService { channelExpiryWeeks: channelExpiryWeeks, options: options ) + self.stateChangedSubject.send() + return entry } } @@ -635,11 +681,13 @@ class BlocktankService { options: CreateOrderOptions ) async throws -> IBtOrder { try await ServiceQueue.background(.core) { - try await createOrder( + let order = try await createOrder( lspBalanceSat: lspBalanceSat, channelExpiryWeeks: channelExpiryWeeks, options: options ) + self.stateChangedSubject.send() + return order } } @@ -663,6 +711,29 @@ class BlocktankService { } } + func upsertOrdersList(_ orders: [IBtOrder]) async throws { + try await ServiceQueue.background(.core) { + try await upsertOrders(orders: orders) + } + } + + func upsertCjitEntriesList(_ cjitEntries: [IcJitEntry]) async throws { + try await ServiceQueue.background(.core) { + try await upsertCjitEntries(entries: cjitEntries) + } + } + + func setInfo(_ info: IBtInfo) async throws { + try await ServiceQueue.background(.core) { + try await upsertInfo(info: info) + } + } + + /// Notifies that blocktank state has changed (e.g., after refreshing data) + func notifyStateChanged() { + stateChangedSubject.send() + } + func open(orderId: String) async throws -> IBtOrder { guard let nodeId = LightningService.shared.nodeId else { throw AppError(serviceError: .nodeNotStarted) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index c30533814..c85087d0e 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LDKNode @@ -8,6 +9,12 @@ class LightningService { var currentWalletIndex: Int = 0 private var currentLogFilePath: String? + private let syncStatusChangedSubject = PassthroughSubject() + + var syncStatusChangedPublisher: AnyPublisher { + syncStatusChangedSubject.eraseToAnyPublisher() + } + static var shared = LightningService() private init() {} @@ -234,6 +241,16 @@ class LightningService { // try? self.setMaxDustHtlcExposureForCurrentChannels() } Logger.info("LDK synced") + + // Emit state change with sync timestamp from node status + let nodeStatus = node.status() + if let latestSyncTimestamp = nodeStatus.latestLightningWalletSyncTimestamp { + let syncTimestamp = UInt64(latestSyncTimestamp) + syncStatusChangedSubject.send(syncTimestamp) + } else { + let syncTimestamp = UInt64(Date().timeIntervalSince1970) + syncStatusChangedSubject.send(syncTimestamp) + } } func newAddress() async throws -> String { diff --git a/Bitkit/Services/ServiceQueue.swift b/Bitkit/Services/ServiceQueue.swift index 705846ac8..59b3b66af 100644 --- a/Bitkit/Services/ServiceQueue.swift +++ b/Bitkit/Services/ServiceQueue.swift @@ -6,6 +6,7 @@ class ServiceQueue { private static let coreQueue = DispatchQueue(label: "core-queue", qos: .userInteractive) private static let migrationQueue = DispatchQueue(label: "migration-queue", qos: .userInteractive) private static let forexQueue = DispatchQueue(label: "forex-queue", qos: .userInteractive) + private static let backupQueue = DispatchQueue(label: "backup-queue", qos: .userInitiated) private init() {} @@ -14,6 +15,7 @@ class ServiceQueue { case core case migration case forex + case backup var queue: DispatchQueue { switch self { @@ -25,6 +27,8 @@ class ServiceQueue { return ServiceQueue.migrationQueue case .forex: return ServiceQueue.forexQueue + case .backup: + return ServiceQueue.backupQueue } } } diff --git a/Bitkit/Services/SettingsStore.swift b/Bitkit/Services/SettingsStore.swift deleted file mode 100644 index 773f101f6..000000000 --- a/Bitkit/Services/SettingsStore.swift +++ /dev/null @@ -1,389 +0,0 @@ -import Combine -import Foundation - -/// Service for managing settings backup/restore operations -class SettingsStore: NSObject { - static let shared = SettingsStore() - - private let defaults = UserDefaults.standard - - // Reactive publishers for settings changes - private let settingsSubject = PassthroughSubject<[String: Any], Never>() - private let widgetsSubject = PassthroughSubject() - - var settingsPublisher: AnyPublisher<[String: Any], Never> { - settingsSubject - .removeDuplicates { old, new in - NSDictionary(dictionary: old).isEqual(to: new) - } - .eraseToAnyPublisher() - } - - var widgetsPublisher: AnyPublisher { - widgetsSubject - .removeDuplicates { old, new in - if let old, let new { - return old.elementsEqual(new) - } - return old == nil && new == nil - } - .eraseToAnyPublisher() - } - - // MARK: - Settings Keys Configuration - - private enum SettingKeyType { - case string(optional: Bool) - case bool - case double(optional: Bool, minValue: Double = 0) - case int(optional: Bool, minValue: Int = 0) - case stringArray(optional: Bool) - } - - // Server settings that require special handling (accessed via services, not directly from UserDefaults) - private static let serverSettingsKeys: [String] = [ - "electrumServer", - "rapidGossipSyncUrl", - ] - - private static let settingsKeyTypes: [String: SettingKeyType] = [ - // String keys (optional) - "primaryDisplay": .string(optional: true), - "bitcoinDisplayUnit": .string(optional: true), - "selectedCurrency": .string(optional: true), - "defaultTransactionSpeed": .string(optional: true), - "coinSelectionMethod": .string(optional: true), - "coinSelectionAlgorithm": .string(optional: true), - - // Bool keys - "showHomeViewEmptyState": .bool, - "hasSeenContactsIntro": .bool, - "hasSeenProfileIntro": .bool, - "hasSeenNotificationsIntro": .bool, - "hasSeenQuickpayIntro": .bool, - "hasSeenShopIntro": .bool, - "hasSeenTransferIntro": .bool, - "hasSeenTransferToSpendingIntro": .bool, - "hasSeenTransferToSavingsIntro": .bool, - "hasSeenWidgetsIntro": .bool, - "enableQuickpay": .bool, - "showWidgets": .bool, - "showWidgetTitles": .bool, - "swipeBalanceToHide": .bool, - "hideBalance": .bool, - "hideBalanceOnOpen": .bool, - "readClipboard": .bool, - "warnWhenSendingOver100": .bool, - "backupVerified": .bool, - "enableNotifications": .bool, - - // Double keys - "quickpayAmount": .double(optional: false), - "highBalanceIgnoreTimestamp": .double(optional: true, minValue: 0), - "backupIgnoreTimestamp": .double(optional: true, minValue: 0), - "appUpdateIgnoreTimestamp": .double(optional: true, minValue: 0), - - // Int keys - "highBalanceIgnoreCount": .int(optional: true, minValue: 0), - - // String array keys (optional, only if not empty) - "lastUsedTags": .stringArray(optional: true), - "dismissedSuggestions": .stringArray(optional: true), - ] - - // All settings keys (for KVO observation) - includes server settings that need special handling - private static var settingsKeys: [String] { - Array(settingsKeyTypes.keys) + serverSettingsKeys - } - - override private init() { - super.init() - - settingsSubject.send(getSettingsDictionary()) - widgetsSubject.send(defaults.data(forKey: "savedWidgets")) - - for key in Self.settingsKeys { - defaults.addObserver(self, forKeyPath: key, options: [.new], context: nil) - } - defaults.addObserver(self, forKeyPath: "savedWidgets", options: [.new], context: nil) - } - - deinit { - for key in Self.settingsKeys { - defaults.removeObserver(self, forKeyPath: key) - } - defaults.removeObserver(self, forKeyPath: "savedWidgets") - } - - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - if Self.settingsKeys.contains(keyPath ?? "") { - settingsSubject.send(getSettingsDictionary()) - } else if keyPath == "savedWidgets" { - widgetsSubject.send(defaults.data(forKey: "savedWidgets")) - } - } - - // MARK: - Field Name Mapping (iOS <-> Android) - - /// Maps iOS field names to Android field names for backup - private static let iosToAndroidFieldMapping: [String: String] = [ - "readClipboard": "enableAutoReadClipboard", - "swipeBalanceToHide": "enableSwipeToHideBalance", - "warnWhenSendingOver100": "enableSendAmountWarning", - "showHomeViewEmptyState": "showEmptyBalanceView", - "bitcoinDisplayUnit": "displayUnit", - "hasSeenTransferToSpendingIntro": "hasSeenSpendingIntro", - "hasSeenTransferToSavingsIntro": "hasSeenSavingsIntro", - "hasSeenQuickpayIntro": "quickPayIntroSeen", - "hasSeenNotificationsIntro": "bgPaymentsIntroSeen", - "enableQuickpay": "isQuickPayEnabled", - "useBiometrics": "isBiometricEnabled", - "requirePinForPayments": "isPinForPaymentsEnabled", - "enableNotifications": "notificationsGranted", - "backupIgnoreTimestamp": "backupWarningIgnoredMillis", - "highBalanceIgnoreTimestamp": "balanceWarningIgnoredMillis", - "highBalanceIgnoreCount": "balanceWarningTimes", - "appUpdateIgnoreTimestamp": "notificationsIgnoredMillis", - ] - - /// Maps Android field names to iOS field names for restore - private static let androidToIosFieldMapping: [String: String] = [ - "enableAutoReadClipboard": "readClipboard", - "enableSwipeToHideBalance": "swipeBalanceToHide", - "enableSendAmountWarning": "warnWhenSendingOver100", - "showEmptyBalanceView": "showHomeViewEmptyState", - "displayUnit": "bitcoinDisplayUnit", - "hasSeenSpendingIntro": "hasSeenTransferToSpendingIntro", - "hasSeenSavingsIntro": "hasSeenTransferToSavingsIntro", - "quickPayIntroSeen": "hasSeenQuickpayIntro", - "bgPaymentsIntroSeen": "hasSeenNotificationsIntro", - "isQuickPayEnabled": "enableQuickpay", - "isBiometricEnabled": "useBiometrics", - "isPinForPaymentsEnabled": "requirePinForPayments", - "notificationsGranted": "enableNotifications", - "backupWarningIgnoredMillis": "backupIgnoreTimestamp", - "balanceWarningIgnoredMillis": "highBalanceIgnoreTimestamp", - "balanceWarningTimes": "highBalanceIgnoreCount", - "notificationsIgnoredMillis": "appUpdateIgnoreTimestamp", - ] - - // MARK: - Coin Selection Conversion Helpers - - /// Converts iOS coinSelectionAlgorithm to Android coinSelectPreference - private static func convertIosAlgorithmToAndroidPreference(_ iosAlgorithm: String) -> String { - switch iosAlgorithm { - case "branchAndBound": - return "BranchAndBound" - case "largestFirst": - return "LargestFirst" - case "oldestFirst": - return "FirstInFirstOut" - case "singleRandomDraw": - return "SingleRandomDraw" - default: - return "BranchAndBound" // Default fallback - } - } - - /// Converts Android coinSelectPreference to iOS coinSelectionAlgorithm - private static func convertAndroidPreferenceToIosAlgorithm(_ androidPreference: String) -> String { - switch androidPreference { - case "BranchAndBound": - return "branchAndBound" - case "LargestFirst": - return "largestFirst" - case "FirstInFirstOut": - return "oldestFirst" - case "SingleRandomDraw": - return "singleRandomDraw" - default: - return "largestFirst" // Default fallback - } - } - - // MARK: - Backup/Restore - - /// Gets all settings from UserDefaults as a dictionary for backup - func getSettingsDictionary() -> [String: Any] { - var dict: [String: Any] = [:] - - // Process all settings keys (excluding server settings which need special handling) - for (key, type) in Self.settingsKeyTypes { - guard defaults.object(forKey: key) != nil else { continue } - - let value: Any? - switch type { - case let .string(optional): - if let stringValue = defaults.string(forKey: key) { - value = stringValue - } else if !optional { - value = "" - } else { - value = nil - } - - case .bool: - value = defaults.bool(forKey: key) - - case let .double(optional, minValue): - let doubleValue = defaults.double(forKey: key) - if doubleValue > minValue { - value = doubleValue - } else if !optional { - value = doubleValue - } else { - value = nil - } - - case let .int(optional, minValue): - let intValue = defaults.integer(forKey: key) - if intValue > minValue { - value = intValue - } else if !optional { - value = intValue - } else { - value = nil - } - - case let .stringArray(optional): - if let arrayValue = defaults.stringArray(forKey: key), !arrayValue.isEmpty { - value = arrayValue - } else if !optional { - value = [] - } else { - value = nil - } - } - - if let value { - // Special handling for coin selection - if key == "coinSelectionMethod", let methodString = value as? String { - // Convert iOS coinSelectionMethod ("manual"/"autopilot") to Android coinSelectAuto (false/true) - let coinSelectAuto = methodString == "autopilot" - dict["coinSelectAuto"] = coinSelectAuto - } else if key == "coinSelectionAlgorithm", let algorithmString = value as? String { - // Convert iOS coinSelectionAlgorithm (camelCase string) to Android coinSelectPreference (PascalCase enum) - let androidPreference = Self.convertIosAlgorithmToAndroidPreference(algorithmString) - dict["coinSelectPreference"] = androidPreference - } else { - // Map iOS field name to Android field name if needed - let androidKey = Self.iosToAndroidFieldMapping[key] ?? key - - // Handle type conversion for Android compatibility - // Android quickPayAmount is Int, iOS is Double - if key == "quickpayAmount", let doubleValue = value as? Double { - dict[androidKey] = Int(doubleValue) - } else { - dict[androidKey] = value - } - } - } - } - - // Server settings (get from services, not directly from UserDefaults) - let electrumConfigService = ElectrumConfigService() - let electrumServer = electrumConfigService.getCurrentServer().url - if !electrumServer.isEmpty { dict["electrumServer"] = electrumServer } - - let rgsConfigService = RgsConfigService() - let rgsServerUrl = rgsConfigService.getCurrentServerUrl() - if !rgsServerUrl.isEmpty { dict["rgsServerUrl"] = rgsServerUrl } - - // Dev Mode (computed value) - dict["isDevModeEnabled"] = Env.isDebug && Env.network != .bitcoin - - return dict - } - - /// Restores settings dictionary to UserDefaults - func restoreSettingsDictionary(_ dict: [String: Any]) { - // Special handling for coin selection (Android format) - if let coinSelectAuto = dict["coinSelectAuto"] as? Bool { - // Convert Android coinSelectAuto (false/true) to iOS coinSelectionMethod ("manual"/"autopilot") - let methodString = coinSelectAuto ? "autopilot" : "manual" - defaults.set(methodString, forKey: "coinSelectionMethod") - } - - if let coinSelectPreference = dict["coinSelectPreference"] as? String { - // Convert Android coinSelectPreference to iOS coinSelectionAlgorithm - let iosAlgorithm = Self.convertAndroidPreferenceToIosAlgorithm(coinSelectPreference) - defaults.set(iosAlgorithm, forKey: "coinSelectionAlgorithm") - } - - // Process all settings keys (excluding server settings which need special handling) - for (iosKey, type) in Self.settingsKeyTypes { - // Skip coin selection keys as they're handled above - if iosKey == "coinSelectionMethod" || iosKey == "coinSelectionAlgorithm" { - continue - } - - // Check both iOS key and Android key (in case backup came from Android) - let androidKey = Self.iosToAndroidFieldMapping[iosKey] ?? iosKey - - // Try Android key first (for cross-platform restore), then iOS key - guard let value = dict[androidKey] ?? dict[iosKey] else { - defaults.removeObject(forKey: iosKey) - continue - } - - switch type { - case .string: - if let stringValue = value as? String { - defaults.set(stringValue, forKey: iosKey) - } - - case .bool: - if let boolValue = value as? Bool { - defaults.set(boolValue, forKey: iosKey) - } - - case .double: - // Handle type conversion: Android uses Int for quickPayAmount, Long for timestamps - if let doubleValue = value as? Double { - defaults.set(doubleValue, forKey: iosKey) - } else if let intValue = value as? Int { - // Convert Int to Double (for quickPayAmount, timestamps in milliseconds) - defaults.set(Double(intValue), forKey: iosKey) - } else if let longValue = value as? Int64 { - // Convert Long (Int64) to Double (for timestamps) - defaults.set(Double(longValue), forKey: iosKey) - } - - case .int: - if let intValue = value as? Int { - defaults.set(intValue, forKey: iosKey) - } else if let doubleValue = value as? Double { - // Convert Double to Int if needed - defaults.set(Int(doubleValue), forKey: iosKey) - } - - case .stringArray: - if let arrayValue = value as? [String] { - defaults.set(arrayValue, forKey: iosKey) - } - } - } - - // Server settings (restore via services, not directly to UserDefaults) - if let electrumServerUrl = dict["electrumServer"] as? String, !electrumServerUrl.isEmpty { - let components = electrumServerUrl.split(separator: ":") - if components.count >= 2 { - let host = String(components[0]) - let portString = String(components[1]) - - let electrumConfigService = ElectrumConfigService() - let protocolType = electrumConfigService.getProtocolForPort(portString) - - let server = ElectrumServer(host: host, portString: portString, protocolType: protocolType) - electrumConfigService.saveServerConfig(server) - Logger.debug("Restored Electrum server: \(electrumServerUrl)", context: "SettingsStore") - } - } - - if let rgsServerUrl = dict["rgsServerUrl"] as? String, !rgsServerUrl.isEmpty { - let rgsConfigService = RgsConfigService() - rgsConfigService.saveServerUrl(rgsServerUrl) - Logger.debug("Restored RGS server URL: \(rgsServerUrl)", context: "SettingsStore") - } - } -} diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index 7f36ff0fc..f7fdf80cd 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -9,7 +9,7 @@ class TransferService { private let blocktankService: BlocktankService init( - storage: TransferStorage = TransferStorage(), + storage: TransferStorage = TransferStorage.shared, lightningService: LightningService, blocktankService: BlocktankService ) { diff --git a/Bitkit/Services/TransferStorage.swift b/Bitkit/Services/TransferStorage.swift index 8c9a73781..d57db4d97 100644 --- a/Bitkit/Services/TransferStorage.swift +++ b/Bitkit/Services/TransferStorage.swift @@ -1,11 +1,20 @@ +import Combine import Foundation /// Handles persistence of Transfer objects using UserDefaults class TransferStorage { + static let shared = TransferStorage() + private let defaults: UserDefaults private let transfersKey = "transfers" - init(suiteName: String? = nil) { + private let transfersChangedSubject = PassthroughSubject() + + var transfersChangedPublisher: AnyPublisher { + transfersChangedSubject.eraseToAnyPublisher() + } + + private init(suiteName: String? = nil) { if let suiteName { defaults = UserDefaults(suiteName: suiteName) ?? .standard } else { @@ -19,6 +28,7 @@ class TransferStorage { transfers.append(transfer) try save(transfers) Logger.info("Inserted transfer: id=\(transfer.id) type=\(transfer.type)", context: "TransferStorage") + transfersChangedSubject.send() } /// Update an existing transfer @@ -28,6 +38,31 @@ class TransferStorage { transfers[index] = transfer try save(transfers) Logger.info("Updated transfer: id=\(transfer.id)", context: "TransferStorage") + transfersChangedSubject.send() + } + } + + /// Upsert a list of transfers (insert or update) + func upsertList(_ transfers: [Transfer]) throws { + var allTransfers = try getAll() + var hasChanges = false + + for transfer in transfers { + if let index = allTransfers.firstIndex(where: { $0.id == transfer.id }) { + // Update existing + allTransfers[index] = transfer + hasChanges = true + } else { + // Insert new + allTransfers.append(transfer) + hasChanges = true + } + } + + if hasChanges { + try save(allTransfers) + Logger.info("Upserted \(transfers.count) transfers", context: "TransferStorage") + transfersChangedSubject.send() } } @@ -62,6 +97,7 @@ class TransferStorage { transfers[index] = transfer try save(transfers) Logger.info("Marked transfer as settled: id=\(id)", context: "TransferStorage") + transfersChangedSubject.send() } } @@ -76,12 +112,11 @@ class TransferStorage { if transfers.count != originalCount { try save(transfers) Logger.info("Deleted \(originalCount - transfers.count) old settled transfers", context: "TransferStorage") + transfersChangedSubject.send() } } - // MARK: - Private Helpers - - private func getAll() throws -> [Transfer] { + func getAll() throws -> [Transfer] { guard let data = defaults.data(forKey: transfersKey) else { return [] } @@ -90,6 +125,8 @@ class TransferStorage { return try decoder.decode([Transfer].self, from: data) } + // MARK: - Private Helpers + private func save(_ transfers: [Transfer]) throws { let encoder = JSONEncoder() let data = try encoder.encode(transfers) diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 510091b20..483f93919 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -107,13 +107,7 @@ enum WidgetsBackupConverter { return nil } - // Android serializes position as Int (JSON number), JSONSerialization may return Int or NSNumber - let position: Int - if let posInt = widgetDict["position"] as? Int { - position = posInt - } else if let posNumber = widgetDict["position"] as? NSNumber { - position = posNumber.intValue - } else { + guard let position = widgetDict["position"] as? Int else { Logger.warn("Invalid position value for widget: \(typeString)", context: "WidgetsBackupConverter") return nil } diff --git a/Bitkit/ViewModels/BackupViewModel.swift b/Bitkit/ViewModels/BackupViewModel.swift index a51b5cc0f..508b84516 100644 --- a/Bitkit/ViewModels/BackupViewModel.swift +++ b/Bitkit/ViewModels/BackupViewModel.swift @@ -25,6 +25,26 @@ class BackupViewModel: ObservableObject { return backupStatuses[category] ?? BackupItemStatus() } + func iconColor(for status: BackupItemStatus) -> Color { + if status.running { + return .yellowAccent + } else if status.synced < status.required { + return .redAccent + } else { + return .greenAccent + } + } + + func backgroundColor(for status: BackupItemStatus) -> Color { + if status.running { + return .yellow16 + } else if status.synced < status.required { + return .red16 + } else { + return .green16 + } + } + /// Formats the status text for display func formatStatusText(for category: BackupCategory) -> String { let status = getStatus(for: category) diff --git a/Bitkit/ViewModels/BlocktankViewModel.swift b/Bitkit/ViewModels/BlocktankViewModel.swift index da29544b5..9f54a1ef4 100644 --- a/Bitkit/ViewModels/BlocktankViewModel.swift +++ b/Bitkit/ViewModels/BlocktankViewModel.swift @@ -76,6 +76,7 @@ class BlocktankViewModel: ObservableObject { func refreshInfo() async throws { info = try await getInfo(refresh: false) // Instant set cached info to state before refreshing info = try await getInfo(refresh: true) + coreService.blocktank.notifyStateChanged() } func refreshOrders() async throws { @@ -94,6 +95,7 @@ class BlocktankViewModel: ObservableObject { cJitEntries = try await coreService.blocktank.cjitOrders(refresh: true) Logger.debug("Orders refreshed") + coreService.blocktank.notifyStateChanged() } func refreshOrder(id: String) async throws -> IBtOrder? { @@ -105,6 +107,7 @@ class BlocktankViewModel: ObservableObject { orders?[index] = refreshedOrder } + coreService.blocktank.notifyStateChanged() return refreshedOrder } @@ -158,6 +161,7 @@ class BlocktankViewModel: ObservableObject { orders?[index] = order } + coreService.blocktank.notifyStateChanged() return order } diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 7901527e7..6a84ad2b3 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -1,3 +1,5 @@ +import Combine +import Foundation import LDKNode import SwiftUI import UserNotifications @@ -38,7 +40,40 @@ extension CoinSelectionAlgorithm { } @MainActor -class SettingsViewModel: ObservableObject { +class SettingsViewModel: NSObject, ObservableObject { + static let shared = SettingsViewModel() + + private let defaults = UserDefaults.standard + private var observedKeys: Set = [] + + // Reactive publishers for settings changes (used by BackupService) + private let settingsSubject = PassthroughSubject<[String: Any], Never>() + private let widgetsSubject = PassthroughSubject() + private let appStateSubject = PassthroughSubject() + + nonisolated var settingsPublisher: AnyPublisher<[String: Any], Never> { + settingsSubject + .removeDuplicates { old, new in + NSDictionary(dictionary: old).isEqual(to: new) + } + .eraseToAnyPublisher() + } + + nonisolated var widgetsPublisher: AnyPublisher { + widgetsSubject + .removeDuplicates { old, new in + if let old, let new { + return old.elementsEqual(new) + } + return old == nil && new == nil + } + .eraseToAnyPublisher() + } + + nonisolated var appStatePublisher: AnyPublisher { + appStateSubject.eraseToAnyPublisher() + } + // Security & Privacy Settings @AppStorage("swipeBalanceToHide") private var _swipeBalanceToHide: Bool = true @@ -86,20 +121,37 @@ class SettingsViewModel: ObservableObject { let electrumConfigService: ElectrumConfigService let rgsConfigService: RgsConfigService - // MARK: - Initialization + // MARK: - Settings Keys Configuration (for backup/restore) - init( - lightningService: LightningService = .shared, - electrumConfigService: ElectrumConfigService = ElectrumConfigService(), - rgsConfigService: RgsConfigService = RgsConfigService() - ) { - self.lightningService = lightningService - self.electrumConfigService = electrumConfigService - self.rgsConfigService = rgsConfigService + // Uses SettingsBackupConfig for configuration data (non-actor type) - // Initialize electrumCurrentServer with current server (stored or default) + // MARK: - Initialization + + override private init() { + lightningService = .shared + electrumConfigService = ElectrumConfigService() + rgsConfigService = RgsConfigService() electrumCurrentServer = electrumConfigService.getCurrentServer() + super.init() + + // Initialize publishers with current state + settingsSubject.send(getSettingsDictionary()) + widgetsSubject.send(defaults.data(forKey: "savedWidgets")) + + // Set up KVO observation + for key in SettingsBackupConfig.settingsKeys { + defaults.addObserver(self, forKeyPath: key, options: [.new], context: nil) + observedKeys.insert(key) + } + defaults.addObserver(self, forKeyPath: "savedWidgets", options: [.new], context: nil) + observedKeys.insert("savedWidgets") + + for key in SettingsBackupConfig.appStateKeys { + defaults.addObserver(self, forKeyPath: key, options: [.new], context: nil) + observedKeys.insert(key) + } + if hideBalanceOnOpen { hideBalance = true } @@ -107,6 +159,22 @@ class SettingsViewModel: ObservableObject { updatePinEnabledState() } + deinit { + for key in observedKeys { + defaults.removeObserver(self, forKeyPath: key) + } + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + if SettingsBackupConfig.settingsKeys.contains(keyPath ?? "") { + settingsSubject.send(getSettingsDictionary()) + } else if keyPath == "savedWidgets" { + widgetsSubject.send(defaults.data(forKey: "savedWidgets")) + } else if SettingsBackupConfig.appStateKeys.contains(keyPath ?? "") { + appStateSubject.send() + } + } + // MARK: - Computed Properties var electrumHasEdited: Bool { @@ -202,4 +270,193 @@ class SettingsViewModel: ObservableObject { let range = NSRange(location: 0, length: url.utf16.count) return regex?.firstMatch(in: url, options: [], range: range) != nil } + + // MARK: - Backup/Restore + + /// Gets all settings from UserDefaults as a dictionary for backup + func getSettingsDictionary() -> [String: Any] { + var dict: [String: Any] = [:] + + for (key, type) in SettingsBackupConfig.settingsKeyTypes { + guard defaults.object(forKey: key) != nil else { continue } + + let value: Any? + switch type { + case let .string(optional): + if let stringValue = defaults.string(forKey: key) { + value = stringValue + } else if !optional { + value = "" + } else { + value = nil + } + case .bool: + value = defaults.bool(forKey: key) + case let .double(optional, minValue): + let doubleValue = defaults.double(forKey: key) + if doubleValue > minValue { + value = doubleValue + } else if !optional { + value = doubleValue + } else { + value = nil + } + case let .int(optional, minValue): + let intValue = defaults.integer(forKey: key) + if intValue > minValue { + value = intValue + } else if !optional { + value = intValue + } else { + value = nil + } + case let .stringArray(optional): + if let arrayValue = defaults.stringArray(forKey: key), !arrayValue.isEmpty { + value = arrayValue + } else if !optional { + value = [] + } else { + value = nil + } + } + + if let value { + if key == "coinSelectionMethod", let methodString = value as? String { + let coinSelectAuto = methodString == "autopilot" + dict["coinSelectAuto"] = coinSelectAuto + } else if key == "coinSelectionAlgorithm", let algorithmString = value as? String { + let androidPreference = SettingsBackupConfig.convertAlgorithm(algorithmString, toAndroid: true) + dict["coinSelectPreference"] = androidPreference + } else { + let androidKey = SettingsBackupConfig.iosToAndroidFieldMapping[key] ?? key + if key == "quickpayAmount", let doubleValue = value as? Double { + dict[androidKey] = Int(doubleValue) + } else { + dict[androidKey] = value + } + } + } + } + + let electrumServer = electrumConfigService.getCurrentServer().url + if !electrumServer.isEmpty { dict["electrumServer"] = electrumServer } + + let rgsServerUrl = rgsConfigService.getCurrentServerUrl() + if !rgsServerUrl.isEmpty { dict["rgsServerUrl"] = rgsServerUrl } + + dict["isDevModeEnabled"] = Env.isDebug && Env.network != .bitcoin + + return dict + } + + /// Restores settings dictionary to UserDefaults + func restoreSettingsDictionary(_ dict: [String: Any]) { + if let coinSelectAuto = dict["coinSelectAuto"] as? Bool { + let methodString = coinSelectAuto ? "autopilot" : "manual" + defaults.set(methodString, forKey: "coinSelectionMethod") + } + + if let coinSelectPreference = dict["coinSelectPreference"] as? String { + let iosAlgorithm = SettingsBackupConfig.convertAlgorithm(coinSelectPreference, toAndroid: false) + defaults.set(iosAlgorithm, forKey: "coinSelectionAlgorithm") + } + + for (iosKey, type) in SettingsBackupConfig.settingsKeyTypes { + if iosKey == "coinSelectionMethod" || iosKey == "coinSelectionAlgorithm" { + continue + } + + let androidKey = SettingsBackupConfig.iosToAndroidFieldMapping[iosKey] ?? iosKey + guard let value = dict[androidKey] ?? dict[iosKey] else { + defaults.removeObject(forKey: iosKey) + continue + } + + switch type { + case .string: + if let stringValue = value as? String { + defaults.set(stringValue, forKey: iosKey) + } + case .bool: + if let boolValue = value as? Bool { + defaults.set(boolValue, forKey: iosKey) + } + case .double: + if let doubleValue = value as? Double { + defaults.set(doubleValue, forKey: iosKey) + } else if let intValue = value as? Int { + defaults.set(Double(intValue), forKey: iosKey) + } else if let longValue = value as? Int64 { + defaults.set(Double(longValue), forKey: iosKey) + } + case .int: + if let intValue = value as? Int { + defaults.set(intValue, forKey: iosKey) + } else if let doubleValue = value as? Double { + defaults.set(Int(doubleValue), forKey: iosKey) + } + case .stringArray: + if let arrayValue = value as? [String] { + defaults.set(arrayValue, forKey: iosKey) + } + } + } + + if let electrumServerUrl = dict["electrumServer"] as? String, !electrumServerUrl.isEmpty { + let components = electrumServerUrl.split(separator: ":") + if components.count >= 2 { + let host = String(components[0]) + let portString = String(components[1]) + let protocolType = electrumConfigService.getProtocolForPort(portString) + let server = ElectrumServer(host: host, portString: portString, protocolType: protocolType) + electrumConfigService.saveServerConfig(server) + } + } + + if let rgsServerUrl = dict["rgsServerUrl"] as? String, !rgsServerUrl.isEmpty { + rgsConfigService.saveServerUrl(rgsServerUrl) + } + } + + /// Gets the current app cache data for backup + func getAppCacheData() -> AppCacheData { + AppCacheData( + hasSeenContactsIntro: defaults.bool(forKey: "hasSeenContactsIntro"), + hasSeenProfileIntro: defaults.bool(forKey: "hasSeenProfileIntro"), + hasSeenNotificationsIntro: defaults.bool(forKey: "hasSeenNotificationsIntro"), + hasSeenQuickpayIntro: defaults.bool(forKey: "hasSeenQuickpayIntro"), + hasSeenShopIntro: defaults.bool(forKey: "hasSeenShopIntro"), + hasSeenTransferIntro: defaults.bool(forKey: "hasSeenTransferIntro"), + hasSeenTransferToSpendingIntro: defaults.bool(forKey: "hasSeenTransferToSpendingIntro"), + hasSeenTransferToSavingsIntro: defaults.bool(forKey: "hasSeenTransferToSavingsIntro"), + hasSeenWidgetsIntro: defaults.bool(forKey: "hasSeenWidgetsIntro"), + showHomeViewEmptyState: defaults.bool(forKey: "showHomeViewEmptyState"), + appUpdateIgnoreTimestamp: defaults.double(forKey: "appUpdateIgnoreTimestamp"), + backupIgnoreTimestamp: defaults.double(forKey: "backupIgnoreTimestamp"), + highBalanceIgnoreCount: defaults.integer(forKey: "highBalanceIgnoreCount"), + highBalanceIgnoreTimestamp: defaults.double(forKey: "highBalanceIgnoreTimestamp"), + dismissedSuggestions: defaults.stringArray(forKey: "dismissedSuggestions") ?? [], + lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [] + ) + } + + /// Restores app cache data from backup + func restoreAppCacheData(_ cache: AppCacheData) { + defaults.set(cache.hasSeenContactsIntro, forKey: "hasSeenContactsIntro") + defaults.set(cache.hasSeenProfileIntro, forKey: "hasSeenProfileIntro") + defaults.set(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") + defaults.set(cache.hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") + defaults.set(cache.hasSeenShopIntro, forKey: "hasSeenShopIntro") + defaults.set(cache.hasSeenTransferIntro, forKey: "hasSeenTransferIntro") + defaults.set(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") + defaults.set(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") + defaults.set(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") + defaults.set(cache.showHomeViewEmptyState, forKey: "showHomeViewEmptyState") + defaults.set(cache.appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") + defaults.set(cache.backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") + defaults.set(cache.highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") + defaults.set(cache.highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") + defaults.set(cache.dismissedSuggestions, forKey: "dismissedSuggestions") + defaults.set(cache.lastUsedTags, forKey: "lastUsedTags") + } } diff --git a/Bitkit/Views/Security/AuthCheck.swift b/Bitkit/Views/Security/AuthCheck.swift index 9c4a236d4..6989373e3 100644 --- a/Bitkit/Views/Security/AuthCheck.swift +++ b/Bitkit/Views/Security/AuthCheck.swift @@ -176,7 +176,7 @@ struct AuthCheck: View { AuthCheck { print("PIN verified!") } - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .environmentObject(WalletViewModel()) .environmentObject(AppViewModel()) .preferredColorScheme(.dark) diff --git a/Bitkit/Views/Security/PinCheckView.swift b/Bitkit/Views/Security/PinCheckView.swift index a0a5e9021..65b5bffcd 100644 --- a/Bitkit/Views/Security/PinCheckView.swift +++ b/Bitkit/Views/Security/PinCheckView.swift @@ -115,7 +115,7 @@ struct PinCheckView: View { print("PIN verified!") } ) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift b/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift index 8ca39f7ff..db21b99ca 100644 --- a/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift @@ -112,7 +112,7 @@ struct CoinSelectionAlgorithmOption: View { } struct CoinSelectionSettingsView: View { - @StateObject private var settingsViewModel = SettingsViewModel() + @EnvironmentObject private var settingsViewModel: SettingsViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { diff --git a/Bitkit/Views/Settings/Backup/BackupSettings.swift b/Bitkit/Views/Settings/Backup/BackupSettings.swift index 539a366fb..fdfd9e29e 100644 --- a/Bitkit/Views/Settings/Backup/BackupSettings.swift +++ b/Bitkit/Views/Settings/Backup/BackupSettings.swift @@ -80,24 +80,8 @@ struct BackupSettings: View { ForEach(BackupCategory.allCases, id: \.self) { category in let status = viewModel.getStatus(for: category) let statusText = viewModel.formatStatusText(for: category) - let iconColor: Color = { - if status.running { - return .yellowAccent - } else if status.synced < status.required { - return .redAccent - } else { - return .greenAccent - } - }() - let backgroundColor: Color = { - if status.running { - return .yellow16 - } else if status.synced < status.required { - return .red16 - } else { - return .green16 - } - }() + let iconColor = viewModel.iconColor(for: status) + let backgroundColor = viewModel.backgroundColor(for: status) let showRetry = status.synced < status.required && !status.running StatusItemView( diff --git a/Bitkit/Views/Settings/General/WidgetsSettingsView.swift b/Bitkit/Views/Settings/General/WidgetsSettingsView.swift index 5d0951df6..1ff458be9 100644 --- a/Bitkit/Views/Settings/General/WidgetsSettingsView.swift +++ b/Bitkit/Views/Settings/General/WidgetsSettingsView.swift @@ -30,7 +30,7 @@ struct WidgetsSettingsView: View { #Preview { NavigationView { WidgetsSettingsView() - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index b613f9bd5..d472e0591 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -82,7 +82,7 @@ struct GeneralSettingsView: View { #Preview { NavigationStack { GeneralSettingsView() - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .environmentObject(CurrencyViewModel()) .environmentObject(AppViewModel()) } diff --git a/Bitkit/Views/Settings/Security/DisablePinView.swift b/Bitkit/Views/Settings/Security/DisablePinView.swift index 591abd669..85040dba9 100644 --- a/Bitkit/Views/Settings/Security/DisablePinView.swift +++ b/Bitkit/Views/Settings/Security/DisablePinView.swift @@ -52,5 +52,5 @@ struct DisablePinView: View { DisablePinView() } .preferredColorScheme(.dark) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } diff --git a/Bitkit/Views/Settings/Security/PinChangeView.swift b/Bitkit/Views/Settings/Security/PinChangeView.swift index 12ebeafed..85bc9d80a 100644 --- a/Bitkit/Views/Settings/Security/PinChangeView.swift +++ b/Bitkit/Views/Settings/Security/PinChangeView.swift @@ -248,7 +248,7 @@ struct PinChangeView: View { } .preferredColorScheme(.dark) .environmentObject(AppViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) .environmentObject(WalletViewModel()) } diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityBiometrics.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityBiometrics.swift index d654cae8a..904b9337f 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityBiometrics.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityBiometrics.swift @@ -137,5 +137,5 @@ struct SecurityBiometrics: View { #Preview { SecurityBiometrics(navigationPath: .constant([.biometrics])) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityNoBiometrics.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityNoBiometrics.swift index 361387705..7546659b6 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityNoBiometrics.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityNoBiometrics.swift @@ -53,5 +53,5 @@ struct SecurityNoBiometrics: View { #Preview { SecurityNoBiometrics(navigationPath: .constant([.biometrics])) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityPin.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityPin.swift index 9198167f1..0e0b0df2a 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityPin.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityPin.swift @@ -87,5 +87,5 @@ struct SecurityPin: View { #Preview { SecurityPin(navigationPath: .constant([.pin])) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift index 701bfbae6..eab409b3c 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift @@ -67,5 +67,5 @@ struct SecuritySuccess: View { #Preview { SecuritySuccess(navigationPath: .constant([.success])) .environmentObject(SheetViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } diff --git a/Bitkit/Views/Settings/SecurityPrivacySettingsView.swift b/Bitkit/Views/Settings/SecurityPrivacySettingsView.swift index 8aaa90828..8cff031b8 100644 --- a/Bitkit/Views/Settings/SecurityPrivacySettingsView.swift +++ b/Bitkit/Views/Settings/SecurityPrivacySettingsView.swift @@ -241,6 +241,6 @@ struct SecurityPrivacySettingsView: View { #Preview { SecurityPrivacySettingsView() .environmentObject(SheetViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift index b5a4d8d91..80b4cb78f 100644 --- a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift +++ b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift @@ -122,7 +122,7 @@ struct TransactionSpeedSettingsView: View { #Preview { NavigationStack { TransactionSpeedSettingsView() - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 1c7fc1cf6..ac894f91d 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -251,7 +251,7 @@ struct ActivityExplorer_Previews: PreviewProvider { .previewDisplayName("Onchain Payment") } .environmentObject(AppViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .environmentObject(CurrencyViewModel()) .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Wallets/HomeView.swift b/Bitkit/Views/Wallets/HomeView.swift index 15f6f0a92..6c69fb112 100644 --- a/Bitkit/Views/Wallets/HomeView.swift +++ b/Bitkit/Views/Wallets/HomeView.swift @@ -135,7 +135,7 @@ struct HomeView: View { HomeView() .environmentObject(ActivityListViewModel()) .environmentObject(AppViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) .environmentObject(WalletViewModel()) .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index ca5a1eff6..86b2f9f4e 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -276,7 +276,7 @@ struct SendAmountView: View { .environmentObject(AppViewModel()) .environmentObject(WalletViewModel()) .environmentObject(CurrencyViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } .presentationDetents([.height(UIScreen.screenHeight - 120)]) } diff --git a/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift b/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift index 2d1430730..ab3f7ef8f 100644 --- a/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift +++ b/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift @@ -204,7 +204,7 @@ extension Array { .environmentObject(AppViewModel()) .environmentObject(WalletViewModel()) .environmentObject(CurrencyViewModel()) - .environmentObject(SettingsViewModel()) + .environmentObject(SettingsViewModel.shared) } .presentationDetents([.height(UIScreen.screenHeight - 120)]) }