diff --git a/Package.swift b/Package.swift index 695601f..d95705b 100644 --- a/Package.swift +++ b/Package.swift @@ -15,10 +15,23 @@ let package = Package( ], dependencies: [ .package(url: "https://source.skip.tools/skip.git", from: "1.0.7"), - .package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0") + .package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip-foundation.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip-sql.git", "0.0.0"..<"2.0.0") ], targets: [ - .target(name: "SatsPrice", dependencies: [.product(name: "SkipUI", package: "skip-ui")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]), + .target( + name: "SatsPrice", + dependencies: [ + .product(name: "SkipUI", package: "skip-ui"), + .product(name: "SkipFoundation", package: "skip-foundation"), + .product(name: "SkipModel", package: "skip-model"), + .product(name: "SkipSQLPlus", package: "skip-sql") + ], + resources: [.process("Resources")], + plugins: [.plugin(name: "skipstone", package: "skip")] + ), .testTarget(name: "SatsPriceTests", dependencies: ["SatsPrice", .product(name: "SkipTest", package: "skip")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]), ] ) diff --git a/Sources/SatsPrice/ContentView.swift b/Sources/SatsPrice/ContentView.swift index 62bfb71..0351d58 100644 --- a/Sources/SatsPrice/ContentView.swift +++ b/Sources/SatsPrice/ContentView.swift @@ -6,11 +6,17 @@ import Combine import SwiftUI public struct ContentView: View { - @ObservedObject private var satsViewModel = SatsViewModel() + let model: SatsPriceModel + + @StateObject private var satsViewModel: SatsViewModel private let dateFormatter: DateFormatter - init() { + init(model: SatsPriceModel) { + self.model = model + + _satsViewModel = StateObject(wrappedValue: SatsViewModel(model: model)) + dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .short @@ -124,6 +130,7 @@ public struct ContentView: View { } } .task { + await satsViewModel.pullSelectedCurrenciesFromDB() await satsViewModel.updatePrice() } .onChange(of: satsViewModel.priceSource) { newPriceSource in @@ -140,5 +147,6 @@ public struct ContentView: View { } #Preview { - ContentView() + let satsPriceModel = try! SatsPriceModel(url: nil) + ContentView(model: satsPriceModel) } diff --git a/Sources/SatsPrice/CurrencyPickerView.swift b/Sources/SatsPrice/CurrencyPickerView.swift index 0c9efb1..3623702 100644 --- a/Sources/SatsPrice/CurrencyPickerView.swift +++ b/Sources/SatsPrice/CurrencyPickerView.swift @@ -31,7 +31,7 @@ struct CurrencyPickerView: View { ForEach(satsViewModel.selectedCurrencies.filter { $0 != currentCurrency }.sorted { $0.identifier < $1.identifier }, id: \.identifier) { currency in Button( action: { - satsViewModel.selectedCurrencies.remove(currency) + satsViewModel.removeSelectedCurrency(currency) }, label: { HStack { @@ -57,7 +57,7 @@ struct CurrencyPickerView: View { ForEach(satsViewModel.currencies.filter { $0 != currentCurrency && !satsViewModel.selectedCurrencies.contains($0) }, id: \.identifier) { currency in Button( action: { - satsViewModel.selectedCurrencies.insert(currency) + satsViewModel.addSelectedCurrency(currency) }, label: { if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) { @@ -80,5 +80,6 @@ struct CurrencyPickerView: View { } #Preview { - CurrencyPickerView(satsViewModel: SatsViewModel()) + let satsPriceModel = try! SatsPriceModel(url: nil) + CurrencyPickerView(satsViewModel: SatsViewModel(model: satsPriceModel)) } diff --git a/Sources/SatsPrice/Model/SatsPriceModel.swift b/Sources/SatsPrice/Model/SatsPriceModel.swift new file mode 100644 index 0000000..962bf06 --- /dev/null +++ b/Sources/SatsPrice/Model/SatsPriceModel.swift @@ -0,0 +1,156 @@ +// This is free software: you can redistribute and/or modify it +// under the terms of the GNU General Public License 3.0 +// as published by the Free Software Foundation https://fsf.org +// +// SatsPriceModel.swift +// sats-price +// +// Created by Terry Yiu on 11/15/24. +// + +import Foundation +import OSLog +import Observation +import SkipSQL + +public struct SelectedCurrency: Identifiable { + public var id: String { + currencyCode + } + + public var currencyCode: String +} + +/// Notification posted by the model when selected currencies change. +extension Notification.Name { + public static var selectedCurrenciesDidChange: Notification.Name { + return Notification.Name("selectedCurrenciesChange") + } +} + +/// Payload of `selectedCurrenciesDidChange` notifications. +public struct SelectedCurrenciesChange { + public let inserts: [SelectedCurrency] + /// Nil set means all records were deleted. + public let deletes: Set? + + public init(inserts: [SelectedCurrency] = [], deletes: [String]? = []) { + self.inserts = inserts + self.deletes = deletes == nil ? nil : Set(deletes!) + } +} + +public actor SatsPriceModel { + private let ctx: SQLContext + private var schemaInitializationResult: Result? + + public init(url: URL?) throws { + ctx = try SQLContext(path: url?.path ?? ":memory:", flags: [.readWrite, .create], logLevel: .info, configuration: .platform) + } + + public func selectedCurrencies() throws -> [SelectedCurrency] { + do { + try initializeSchema() + let statement = try ctx.prepare(sql: "SELECT currencyCode FROM SelectedCurrency") + defer { + do { + try statement.close() + } catch { + logger.warning("Failed to close statement: \(error)") + } + } + + var selectedCurrencies: [SelectedCurrency] = [] + + while try statement.next() { + let currencyCode = statement.string(at: 0) ?? "" + selectedCurrencies.append(SelectedCurrency(currencyCode: currencyCode)) + } + + return selectedCurrencies + } catch { + logger.error("Failed to get selected currencies from DB. Error: \(error)") + throw error + } + } + + @discardableResult + public func insert(_ selectedCurrency: SelectedCurrency) throws -> [SelectedCurrency] { + try initializeSchema() + let statement = try ctx.prepare(sql: "INSERT INTO SelectedCurrency (currencyCode) VALUES (?)") + defer { + do { + try statement.close() + } catch { + logger.warning("Failed to close statement: \(error)") + } + } + + var insertedItems: [SelectedCurrency] = [] + try ctx.transaction { + statement.reset() + let values = Self.bindingValues(for: selectedCurrency) + try statement.update(parameters: values) + + insertedItems.append(selectedCurrency) + } + NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(inserts: insertedItems)) + return insertedItems + } + + private static func bindingValues(for selectedCurrency: SelectedCurrency) -> [SQLValue] { + return [ + .text(selectedCurrency.currencyCode) + ] + } + + @discardableResult + public func deleteSelectedCurrency(currencyCode: String) throws -> Int { + try initializeSchema() + try ctx.exec(sql: "DELETE FROM SelectedCurrency WHERE currencyCode = ?", parameters: [.text(currencyCode)]) + NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(deletes: [currencyCode])) + return Int(ctx.changes) + } + + private func initializeSchema() throws { + switch schemaInitializationResult { + case .success: + return + case .failure(let failure): + throw failure + case nil: + break + } + + do { + var currentVersion = try currentSchemaVersion() + currentVersion = try migrateSchema(v: Int64(1), current: currentVersion, ddl: """ + CREATE TABLE SelectedCurrency (currencyCode TEXT PRIMARY KEY NOT NULL) + """) + // Future column additions, etc here... + schemaInitializationResult = .success(()) + } catch { + schemaInitializationResult = .failure(error) + throw error + } + } + + private func currentSchemaVersion() throws -> Int64 { + try ctx.exec(sql: "CREATE TABLE IF NOT EXISTS SchemaVersion (id INTEGER PRIMARY KEY, version INTEGER)") + try ctx.exec(sql: "INSERT OR IGNORE INTO SchemaVersion (id, version) VALUES (0, 0)") + return try ctx.query(sql: "SELECT version FROM SchemaVersion").first?.first?.integerValue ?? Int64(0) + } + + private func migrateSchema(v version: Int64, current: Int64, ddl: String) throws -> Int64 { + guard current < version else { + return current + } + let startTime = Date.now + try ctx.transaction { + try ctx.exec(sql: ddl) + try ctx.exec(sql: "UPDATE SchemaVersion SET version = ?", parameters: [.integer(version)]) + } + logger.log("Updated database schema to \(version) in \(Date.now.timeIntervalSince1970 - startTime.timeIntervalSince1970)") + return version + } +} diff --git a/Sources/SatsPrice/SatsPriceApp.swift b/Sources/SatsPrice/SatsPriceApp.swift index 3d12d0e..c218656 100644 --- a/Sources/SatsPrice/SatsPriceApp.swift +++ b/Sources/SatsPrice/SatsPriceApp.swift @@ -11,6 +11,9 @@ let logger: Logger = Logger(subsystem: "xyz.tyiu.SatsPrice", category: "SatsPric /// The Android SDK number we are running against, or `nil` if not running on Android let androidSDK = ProcessInfo.processInfo.environment["android.os.Build.VERSION.SDK_INT"].flatMap({ Int($0) }) +/// The shared data model. +private let model = try! SatsPriceModel(url: URL.documentsDirectory.appendingPathComponent("satsprice.sqlite")) + /// The shared top-level view for the app, loaded from the platform-specific App delegates below. /// /// The default implementation merely loads the `ContentView` for the app and logs a message. @@ -19,7 +22,7 @@ public struct RootView : View { } public var body: some View { - ContentView() + ContentView(model: model) .task { logger.log("Welcome to Skip on \(androidSDK != nil ? "Android" : "Darwin")!") logger.warning("Skip app logs are viewable in the Xcode console for iOS; Android logs can be viewed in Studio or using adb logcat") diff --git a/Sources/SatsPrice/SatsViewModel.swift b/Sources/SatsPrice/SatsViewModel.swift index 473e2d4..15c5338 100644 --- a/Sources/SatsPrice/SatsViewModel.swift +++ b/Sources/SatsPrice/SatsViewModel.swift @@ -13,6 +13,8 @@ import Foundation import SwiftUI class SatsViewModel: ObservableObject { + let model: SatsPriceModel + @Published var lastUpdated: Date? @Published var priceSourceInternal: PriceSource = .coinbase @@ -20,7 +22,6 @@ class SatsViewModel: ObservableObject { @Published var satsStringInternal: String = "" @Published var btcStringInternal: String = "" - @Published var selectedCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD") @Published var selectedCurrencies = Set() @Published var currencyValueStrings: [Locale.Currency: String] = [:] @@ -28,6 +29,10 @@ class SatsViewModel: ObservableObject { let currentCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD") + init(model: SatsPriceModel) { + self.model = model + } + var currencies: [Locale.Currency] { let commonISOCurrencyCodes = Set(Locale.commonISOCurrencyCodes) if commonISOCurrencyCodes.contains(currentCurrency.identifier) { @@ -40,6 +45,34 @@ class SatsViewModel: ObservableObject { } } + @MainActor + func pullSelectedCurrenciesFromDB() async { + do { + let selectedCurrencies = Set(try await model.selectedCurrencies().compactMap { Locale.Currency($0.currencyCode) }) + let currenciesToAdd = selectedCurrencies.subtracting(self.selectedCurrencies) + let currenciesToRemove = self.selectedCurrencies.subtracting(selectedCurrencies) + + self.selectedCurrencies.subtract(currenciesToRemove) + self.selectedCurrencies.formUnion(currenciesToAdd) + } catch { + logger.error("Unable to pull selected currencies from DB. Error: \(error)") + } + } + + func addSelectedCurrency(_ currency: Locale.Currency) { + selectedCurrencies.insert(currency) + Task { + try await model.insert(SelectedCurrency(currencyCode: currency.identifier)) + } + } + + func removeSelectedCurrency(_ currency: Locale.Currency) { + selectedCurrencies.remove(currency) + Task { + try await model.deleteSelectedCurrency(currencyCode: currency.identifier) + } + } + var priceSource: PriceSource { get { priceSourceInternal