diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index e7c80f00c..b01a6db17 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -190,6 +190,7 @@ Fonts/InterTight-Regular.ttf, Constants/WidgetEnv.swift, Fonts/InterTight-SemiBold.ttf, + Components/Widgets/WeatherWidgetContent.swift, Models/BlocksWidgetData.swift, Models/BlocksWidgetFields.swift, Models/BlocksWidgetOptions.swift, @@ -198,12 +199,17 @@ Models/NewsWidgetOptions.swift, Models/PriceWidgetData.swift, Models/PriceWidgetOptions.swift, + Models/WeatherWidgetData.swift, + Models/WeatherWidgetOptions.swift, Services/Widgets/BlocksHomeScreenWidgetOptionsStore.swift, + Services/Widgets/MempoolWeatherAPI.swift, Services/Widgets/NewsHomeScreenWidgetOptionsStore.swift, Services/Widgets/PriceHomeScreenWidgetOptionsStore.swift, + Services/Widgets/WeatherHomeScreenWidgetOptionsStore.swift, Styles/Colors.swift, Styles/Fonts.swift, Styles/TextStyle.swift, + Utilities/LocalizeHelpers.swift, ); target = 4A319B4E2E8F24F2002B9AC9 /* BitkitWidgetExtension */; }; diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 90a005598..33d07b228 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -27,6 +27,7 @@ struct AppScene: View { @StateObject private var transferTracking: TransferTrackingManager @StateObject private var channelDetails = ChannelDetailsViewModel.shared @StateObject private var migrations = MigrationsService.shared + @StateObject private var languageManager = LanguageManager.shared @StateObject private var pubkyProfile = PubkyProfileManager() @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() diff --git a/Bitkit/Components/Widgets/WeatherWidget.swift b/Bitkit/Components/Widgets/WeatherWidget.swift index d44504726..2c4cb4495 100644 --- a/Bitkit/Components/Widgets/WeatherWidget.swift +++ b/Bitkit/Components/Widgets/WeatherWidget.swift @@ -1,78 +1,13 @@ import SwiftUI -/// Options for configuring the WeatherWidget -struct WeatherWidgetOptions: Codable, Equatable { - var showStatus: Bool = true - var showText: Bool = true - var showMedian: Bool = true - var showNextBlockFee: Bool = true -} - -/// Fee condition enum matching the React Native implementation -enum FeeCondition: String, Codable { - case good - case average - case poor - - var title: String { - switch self { - case .good: - return t("widgets__weather__condition__good__title") - case .average: - return t("widgets__weather__condition__average__title") - case .poor: - return t("widgets__weather__condition__poor__title") - } - } - - var description: String { - switch self { - case .good: - return t("widgets__weather__condition__good__description") - case .average: - return t("widgets__weather__condition__average__description") - case .poor: - return t("widgets__weather__condition__poor__description") - } - } - - var icon: String { - switch self { - case .good: - return "☀️" - case .average: - return "⛅" - case .poor: - return "⛈️" - } - } -} - -/// Weather widget data model -struct WeatherData: Codable { - let condition: FeeCondition - let currentFee: String - let nextBlockFee: Int -} - -/// A widget that displays Bitcoin fee weather information struct WeatherWidget: View { - /// Configuration options for the widget var options: WeatherWidgetOptions = .init() - - /// Flag indicating if the widget is in editing mode var isEditing: Bool = false - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// View model for handling weather data @StateObject private var viewModel = WeatherViewModel.shared - - /// Currency view model for currency conversion @EnvironmentObject private var currency: CurrencyViewModel - /// Initialize the widget init( options: WeatherWidgetOptions = WeatherWidgetOptions(), isEditing: Bool = false, @@ -89,96 +24,29 @@ struct WeatherWidget: View { isEditing: isEditing, onEditingEnd: onEditingEnd ) { - VStack(spacing: 0) { - if viewModel.isLoading { - WidgetContentBuilder.loadingView() - } else if viewModel.error != nil { - WidgetContentBuilder.errorView(t("widgets__weather__error")) - } else if let data = viewModel.weatherData { - VStack(spacing: 16) { - // Status condition with icon - if options.showStatus { - HStack(spacing: 16) { - WeatherTitleText(data.condition.title) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(data.condition.icon) - .font(.system(size: 52)) - .frame(width: 52, height: 52) - } - } - - // Description text - if options.showText { - BodyMText(data.condition.description, textColor: .textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - // Fee information rows - if options.showMedian || options.showNextBlockFee { - VStack(spacing: 8) { - if options.showMedian { - HStack(spacing: 0) { - HStack { - BodySSBText(t("widgets__weather__current_fee"), textColor: .textSecondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - BodySSBText(data.currentFee) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(minHeight: 20) - } - - if options.showNextBlockFee { - HStack(spacing: 0) { - HStack { - BodySSBText(t("widgets__weather__next_block"), textColor: .textSecondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - BodySSBText("\(data.nextBlockFee) ₿/vByte") - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(minHeight: 20) - } - } - } - } - } - } + content } - .onAppear { - // Inject currency dependency into view model + .task { viewModel.setCurrencyViewModel(currency) - // Start data updates viewModel.startUpdates() } } -} -struct WeatherTitleText: View { - let text: String - - init(_ text: String) { - self.text = text - } - - var body: some View { - Text(text) - .font(Fonts.bold(size: 22)) - .foregroundColor(.textPrimary) - .kerning(0) - .environment(\._lineHeightMultiple, 0.85) + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.weatherData == nil { + WidgetContentBuilder.loadingView() + } else if viewModel.error != nil && viewModel.weatherData == nil { + WidgetContentBuilder.errorView(t("widgets__weather__error")) + } else if let data = viewModel.weatherData { + WeatherWidgetWideContent( + data: data, + metric: options.selectedMetric, + conditionTitle: t(data.condition.titleKey), + conditionDescription: t(data.condition.descriptionKey), + metricLabel: t(options.selectedMetric.labelKey) + ) + } } } diff --git a/Bitkit/Components/Widgets/WeatherWidgetContent.swift b/Bitkit/Components/Widgets/WeatherWidgetContent.swift new file mode 100644 index 000000000..793e8958e --- /dev/null +++ b/Bitkit/Components/Widgets/WeatherWidgetContent.swift @@ -0,0 +1,138 @@ +import SwiftUI +import WidgetKit + +// MARK: - Display metric helpers + +extension WeatherDisplayMetric { + var labelKey: String { + switch self { + case .fiatFee, .satsFee: + return "widgets__weather__current_fee" + case .nextBlockFee: + return "widgets__weather__next_block" + } + } + + func value(from data: CachedWeather) -> String { + switch self { + case .fiatFee: + return data.currentFeeFiat + case .satsFee: + return "₿ \(data.currentFeeSats)" + case .nextBlockFee: + return "\(data.nextBlockFee) ₿/VBYTE" + } + } + + var fallbackPreviewValue: String { + switch self { + case .fiatFee: return "$ 0.52" + case .satsFee: return "₿ 520" + case .nextBlockFee: return "6 ₿/VBYTE" + } + } +} + +// MARK: - Shared metric block + +struct WeatherFeeMetric: View { + let label: String + let value: String + var valueColor: Color = .greenAccent + var valueSize: CGFloat = 30 + /// Use a smaller label (`FootnoteText`) so the caption doesn't dominate the value + /// in the space-constrained compact widget. + var compactLabel: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + labelView + Text(value) + .font(Fonts.bold(size: valueSize)) + .foregroundColor(valueColor) + .kerning(-1) + .lineLimit(1) + .minimumScaleFactor(0.9) + .widgetAccentable() + } + } + + @ViewBuilder + private var labelView: some View { + if compactLabel { + FootnoteText(label, textColor: .white64) + .textCase(.uppercase) + .lineLimit(1) + .minimumScaleFactor(0.7) + } else { + CaptionMText(label, textColor: .white64) + .textCase(.uppercase) + .lineLimit(1) + } + } +} + +struct WeatherWidgetWideContent: View { + let data: CachedWeather + let metric: WeatherDisplayMetric + let conditionTitle: String + let conditionDescription: String + let metricLabel: String + + var body: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + SubtitleText(conditionTitle, textColor: .white) + BodySText(conditionDescription, textColor: .white80) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + WeatherFeeMetric( + label: metricLabel, + value: metric.value(from: data), + valueColor: data.condition.valueColor + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(data.condition.icon) + .font(.system(size: 82)) + .frame(width: 82, height: 82) + .widgetAccentable() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct WeatherWidgetCompactContent: View { + let data: CachedWeather + let metric: WeatherDisplayMetric + let conditionTitle: String + let metricLabel: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading) { + Text(data.condition.icon) + .font(.system(size: 58)) + .minimumScaleFactor(0.85) + .widgetAccentable() + SubtitleText(conditionTitle, textColor: .white) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + WeatherFeeMetric( + label: metricLabel, + value: metric.value(from: data), + valueColor: data.condition.valueColor, + valueSize: 28, + compactLabel: true + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 023305007..869c6313f 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -447,6 +447,8 @@ struct MainNavView: View { BlocksWidgetPreviewView() case .facts: FactsWidgetPreviewView() + case .weather: + WeatherWidgetPreviewView() default: WidgetDetailView(id: widgetType) } diff --git a/Bitkit/Managers/LanguageManager.swift b/Bitkit/Managers/LanguageManager.swift index 7f524b2b9..b8819ac1c 100644 --- a/Bitkit/Managers/LanguageManager.swift +++ b/Bitkit/Managers/LanguageManager.swift @@ -1,11 +1,15 @@ import Foundation import SwiftUI +import WidgetKit /// Manages the app's language settings and provides dynamic language switching @MainActor final class LanguageManager: ObservableObject { static let shared = LanguageManager() + nonisolated static let appGroupSuiteName = "group.bitkit" + nonisolated static let selectedLanguageCodeKey = "selectedLanguageCode" + @Published var currentLanguage: SupportedLanguage @AppStorage("selectedLanguageCode") private var selectedLanguageCode: String = "" @@ -21,6 +25,8 @@ final class LanguageManager: ObservableObject { } else { currentLanguage = SupportedLanguage.language(for: selectedLanguageCode) } + + syncSelectedLanguageToAppGroup(selectedLanguageCode) } /// Sets the app language and persists the selection @@ -31,6 +37,21 @@ final class LanguageManager: ObservableObject { // Set the language preference for the current session UserDefaults.standard.set([language.code], forKey: "AppleLanguages") UserDefaults.standard.synchronize() + + syncSelectedLanguageToAppGroup(language.code) + } + + private func syncSelectedLanguageToAppGroup(_ code: String) { + Self.mirrorToAppGroup(code: code) + } + + /// Thread-safe mirror of the language code into the App Group, suitable for callers that + /// can't reach the `@MainActor` instance (e.g. `MigrationsService` writing during restore + /// flows). `UserDefaults` and `WidgetCenter` are both safe to call off the main actor. + nonisolated static func mirrorToAppGroup(code: String) { + guard let defaults = UserDefaults(suiteName: appGroupSuiteName) else { return } + defaults.set(code, forKey: selectedLanguageCodeKey) + WidgetCenter.shared.reloadAllTimelines() } /// Gets the display name of the current language in the current language diff --git a/Bitkit/Models/WeatherWidgetData.swift b/Bitkit/Models/WeatherWidgetData.swift new file mode 100644 index 000000000..aeb0719ad --- /dev/null +++ b/Bitkit/Models/WeatherWidgetData.swift @@ -0,0 +1,202 @@ +import Foundation +import SwiftUI + +/// Bitcoin fee weather condition (good/average/poor). +enum FeeCondition: String, Codable { + case good + case average + case poor + + var titleKey: String { + switch self { + case .good: return "widgets__weather__condition__good__title" + case .average: return "widgets__weather__condition__average__title" + case .poor: return "widgets__weather__condition__poor__title" + } + } + + var shortTitleKey: String { + switch self { + case .good: return "widgets__weather__condition__good__short_title" + case .average: return "widgets__weather__condition__average__short_title" + case .poor: return "widgets__weather__condition__poor__short_title" + } + } + + var descriptionKey: String { + switch self { + case .good: return "widgets__weather__condition__good__description" + case .average: return "widgets__weather__condition__average__description" + case .poor: return "widgets__weather__condition__poor__description" + } + } + + var icon: String { + switch self { + case .good: return "☀️" + case .average: return "⛅" + case .poor: return "⛈️" + } + } + + /// Accent color applied to the fee value, signalling how favorable current fees are. + var valueColor: Color { + switch self { + case .good: return .greenAccent + case .average: return .yellowAccent + case .poor: return .brandAccent + } + } +} + +// MARK: - Classification algorithm + +extension FeeCondition { + static let usdGoodThreshold: Double = 1.0 + + /// - midSatsPerVbyte: current median fee rate (mempool `halfHourFee`, BitkitCore `fees.mid`). + /// - totalSats: `midSatsPerVbyte × 140 vBytes` — average native-segwit transaction cost. + /// - usdPerBtc: latest BTC/USD spot price (optional). When the resulting total fee in USD is + /// at or below `usdGoodThreshold`, the condition is always `.good`. + /// - percentile: 33rd/66th percentile of the last 3 months of median block fees. Optional — + /// if missing the function returns `.average`. + static func evaluate( + midSatsPerVbyte: Double, + totalSats: Int, + usdPerBtc: Double?, + percentile: FeePercentile? + ) -> FeeCondition { + if let usdPerBtc, usdPerBtc > 0 { + let usdValue = Double(totalSats) / 100_000_000 * usdPerBtc + if usdValue <= usdGoodThreshold { return .good } + } + guard let percentile else { return .average } + if midSatsPerVbyte <= percentile.lowThreshold { return .good } + if midSatsPerVbyte >= percentile.highThreshold { return .poor } + return .average + } +} + +struct BlockFeeRates: Codable { + let avgFee_50: Double +} + +/// 33rd / 66th percentile thresholds computed from a 3-month window of median block fees. +struct FeePercentile: Codable, Equatable { + let lowThreshold: Double + let highThreshold: Double + + static let percentileLow = 0.33 + static let percentileHigh = 0.66 + + init(lowThreshold: Double, highThreshold: Double) { + self.lowThreshold = lowThreshold + self.highThreshold = highThreshold + } + + /// Computes percentiles from a raw history. Returns `nil` if `history` is empty so callers + /// can fall back to a default classification. + init?(history: [BlockFeeRates]) { + guard !history.isEmpty else { return nil } + let sorted = history.map(\.avgFee_50).sorted() + let lowIndex = min(sorted.count - 1, Int(Double(sorted.count) * Self.percentileLow)) + let highIndex = min(sorted.count - 1, Int(Double(sorted.count) * Self.percentileHigh)) + lowThreshold = sorted[lowIndex] + highThreshold = sorted[highIndex] + } +} + +// MARK: - Cached weather payload + +struct CachedWeather: Codable, Equatable { + let condition: FeeCondition + /// Pre-formatted fiat string (e.g. "$ 0.52"). + let currentFeeFiat: String + /// Median fee in sats (e.g. 520). + let currentFeeSats: Int + /// Next-block inclusion fee rate in sats/vByte (e.g. 6). + let nextBlockFee: Int +} + +/// App Group entry pairing a cached `FeePercentile` with the time it was written, so the +/// widget extension can apply a TTL without refetching the 3-month history on every refresh. +struct CachedFeePercentile: Codable, Equatable { + let percentile: FeePercentile + let timestamp: Date +} + +/// App Group cache reader/writer used by both the main app and the widget extension. +enum WeatherWidgetCache { + static let appGroupSuiteName = "group.bitkit" + private static let latestKey = "weather_widget_latest_v1" + private static let latestTimestampKey = "weather_widget_latest_timestamp_v1" + private static let percentileKey = "weather_widget_percentile_v1" + private static let legacyStandardKey = "weather_widget_cache" + + /// How long the cached percentile is considered fresh. + static let percentileTTL: TimeInterval = 30 * 60 + + /// How long the cached `CachedWeather` is considered authoritative. Beyond this the OS + /// widget falls back to its own fetch so it stays useful between app sessions. + static let cacheFreshnessTTL: TimeInterval = 10 * 60 + + private static func defaults() -> UserDefaults { + UserDefaults(suiteName: appGroupSuiteName) ?? .standard + } + + static func saveLatest(_ data: CachedWeather, now: Date = Date()) { + guard let encoded = try? JSONEncoder().encode(data) else { return } + let store = defaults() + store.set(encoded, forKey: latestKey) + store.set(now.timeIntervalSince1970, forKey: latestTimestampKey) + } + + /// Returns whatever's cached, regardless of age. Used by the in-app stale-while-revalidate + /// flow that displays cached data immediately and refreshes in the background. + static func loadLatest() -> CachedWeather? { + guard let data = defaults().data(forKey: latestKey), + let decoded = try? JSONDecoder().decode(CachedWeather.self, from: data) + else { + return nil + } + return decoded + } + + /// Returns the cached value only when its sibling timestamp is within + /// `cacheFreshnessTTL`. Used by the OS widget timeline so it can decide whether to fall + /// back to its own fetch when the main app hasn't refreshed in a while. + static func loadLatestIfFresh(now: Date = Date()) -> CachedWeather? { + guard let cached = loadLatest() else { return nil } + let timestamp = defaults().double(forKey: latestTimestampKey) + guard timestamp > 0, now.timeIntervalSince1970 - timestamp <= cacheFreshnessTTL else { + return nil + } + return cached + } + + static func savePercentile(_ percentile: FeePercentile, now: Date = Date()) { + let entry = CachedFeePercentile(percentile: percentile, timestamp: now) + guard let encoded = try? JSONEncoder().encode(entry) else { return } + defaults().set(encoded, forKey: percentileKey) + } + + /// Returns the cached percentile only when it's within the TTL window. + static func loadPercentile(now: Date = Date()) -> FeePercentile? { + guard let data = defaults().data(forKey: percentileKey), + let entry = try? JSONDecoder().decode(CachedFeePercentile.self, from: data) + else { + return nil + } + guard now.timeIntervalSince(entry.timestamp) <= percentileTTL else { return nil } + return entry.percentile + } + + static func invalidateFreshness() { + defaults().removeObject(forKey: latestTimestampKey) + } + + /// One-time cleanup of the pre-App-Group cache that lived in `UserDefaults.standard`. + static func legacyDropStandardSuiteCache() { + UserDefaults.standard.removeObject(forKey: legacyStandardKey) + } +} diff --git a/Bitkit/Models/WeatherWidgetOptions.swift b/Bitkit/Models/WeatherWidgetOptions.swift new file mode 100644 index 000000000..c229b881c --- /dev/null +++ b/Bitkit/Models/WeatherWidgetOptions.swift @@ -0,0 +1,55 @@ +import Foundation + +enum WeatherDisplayMetric: String, Codable, CaseIterable { + case fiatFee + case satsFee + case nextBlockFee +} + +struct WeatherWidgetOptions: Codable, Equatable { + var selectedMetric: WeatherDisplayMetric = .fiatFee + + init(selectedMetric: WeatherDisplayMetric = .fiatFee) { + self.selectedMetric = selectedMetric + } + + private enum CodingKeys: String, CodingKey { + case selectedMetric + // Legacy v60 keys — still read on decode so users upgrading from the four-toggle layout + // keep their prior choice (and backup export sees the right metric, since it decodes + // through this type before converting to the cross-platform format). + case showMedian + case showNextBlockFee + } + + /// Custom decoder so users upgrading from the v60 four-toggle blob aren't silently reset to + /// `.fiatFee`. Mapping rules, in priority order: + /// 1. New `selectedMetric` key wins when present. + /// 2. `showMedian` (legacy "Current Fee" toggle) → `.fiatFee`. + /// 3. `showNextBlockFee` alone → `.nextBlockFee`. + /// 4. Neither present / both `false` → default `.fiatFee`. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let metric = try container.decodeIfPresent(WeatherDisplayMetric.self, forKey: .selectedMetric) { + selectedMetric = metric + return + } + + let showMedian = try container.decodeIfPresent(Bool.self, forKey: .showMedian) ?? false + let showNextBlockFee = try container.decodeIfPresent(Bool.self, forKey: .showNextBlockFee) ?? false + + if showMedian { + selectedMetric = .fiatFee + } else if showNextBlockFee { + selectedMetric = .nextBlockFee + } else { + selectedMetric = .fiatFee + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(selectedMetric, forKey: .selectedMetric) + } +} diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 60baacd62..4df41828d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -998,11 +998,14 @@ "widgets__blocks__error" = "No s\'han pogut obtenir les dades dels blocs"; "widgets__facts__name" = "Bitcoin Fets"; "widgets__weather__condition__good__title" = "Condicions favorables"; -"widgets__weather__condition__good__description" = "Tot clar. Ara seria un bon moment per fer transaccions a la blockchain."; +"widgets__weather__condition__good__description" = "Ara seria un bon moment per fer transaccions a la blockchain."; "widgets__weather__condition__average__title" = "Condicions mitjanes"; -"widgets__weather__condition__average__description" = "La taxa del proper bloc està prop dels valors mitjans mensuals."; +"widgets__weather__condition__average__description" = "La taxa del proper bloc està prop dels valors mitjans mensuals i anuals."; "widgets__weather__condition__poor__title" = "Condicions dolentes"; -"widgets__weather__condition__poor__description" = "Si no tens pressa per fer transaccions, pot ser millor esperar una mica."; +"widgets__weather__condition__poor__description" = "Si no tens pressa per fer transaccions, pot ser millor esperar."; "widgets__weather__current_fee" = "Comissió mitjana actual"; "widgets__weather__next_block" = "Inclusió al proper bloc"; "widgets__weather__error" = "No s\'ha pogut obtenir el temps de comissions actual"; +"widgets__weather__condition__good__short_title" = "Favorables"; +"widgets__weather__condition__average__short_title" = "Mitjanes"; +"widgets__weather__condition__poor__short_title" = "Dolentes"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 96c46a14b..ab02e1b99 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1164,11 +1164,14 @@ "widgets__weather__name" = "Počasí v bitcoinu"; "widgets__weather__description" = "Zjistěte, kdy je vhodná doba pro transakce v blockchainu bitcoinu."; "widgets__weather__condition__good__title" = "Příznivé podmínky"; -"widgets__weather__condition__good__description" = "Všude čisto. Nyní je vhodná doba pro transakce na blockchainu."; +"widgets__weather__condition__good__description" = "Nyní je vhodná doba pro transakce na blockchainu."; "widgets__weather__condition__average__title" = "Průměrné podmínky"; -"widgets__weather__condition__average__description" = "Poplatek dalšího bloku se blíží měsíčním průměrům."; +"widgets__weather__condition__average__description" = "Poplatek dalšího bloku se blíží měsíčním a ročním průměrům."; "widgets__weather__condition__poor__title" = "Špatné podmínky"; -"widgets__weather__condition__poor__description" = "Pokud na transakci nespěcháte, je lepší chvíli počkat."; +"widgets__weather__condition__poor__description" = "Pokud na transakci nespěcháte, je lepší počkat."; "widgets__weather__current_fee" = "Aktuální průměrný poplatek"; "widgets__weather__next_block" = "Zařazení do dalšího bloku"; "widgets__weather__error" = "Nepodařilo se získat aktuální poplatkovou úroveň"; +"widgets__weather__condition__good__short_title" = "Příznivé"; +"widgets__weather__condition__average__short_title" = "Průměrné"; +"widgets__weather__condition__poor__short_title" = "Špatné"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 1ab39565e..869273695 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1160,11 +1160,14 @@ "widgets__weather__name" = "Bitcoin Wetter"; "widgets__weather__description" = "Finde heraus, wann es eine gute Zeit ist, um auf der Bitcoin blockchain Überweisungen zu tätigen."; "widgets__weather__condition__good__title" = "Günstige Bedingungen"; -"widgets__weather__condition__good__description" = "Alles klar. Jetzt wäre eine gute Zeit, um auf der Bitcoin blockchain Überweisungen zu tätigen."; +"widgets__weather__condition__good__description" = "Jetzt wäre eine gute Zeit, um auf der Bitcoin blockchain Überweisungen zu tätigen."; "widgets__weather__condition__average__title" = "Durchschnittliche Bedingungen"; -"widgets__weather__condition__average__description" = "Die nächste Block-Rate ist nahe dem Monatsdurchschnitt."; +"widgets__weather__condition__average__description" = "Die nächste Block-Rate ist nahe dem Monats- und Jahresdurchschnitt."; "widgets__weather__condition__poor__title" = "Schlechte Bedingungen"; -"widgets__weather__condition__poor__description" = "Wenn du es nicht eilig hast mit deiner Transaktion, könnte es besser sein etwas zu warten."; +"widgets__weather__condition__poor__description" = "Wenn du es nicht eilig hast mit deiner Transaktion, könnte es besser sein zu warten."; "widgets__weather__current_fee" = "Momentane Durchschnittsgebühr"; "widgets__weather__next_block" = "Nächster Block-Einschluss"; "widgets__weather__error" = "Konnte aktuelle Gebührenwetterlage nicht ermitteln."; +"widgets__weather__condition__good__short_title" = "Günstig"; +"widgets__weather__condition__average__short_title" = "Durchschnittlich"; +"widgets__weather__condition__poor__short_title" = "Schlecht"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index de16aefce..8c7654fbe 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -856,11 +856,14 @@ "widgets__news__error" = "Δεν ήταν δυνατή η λήψη τελευταίων ειδήσεων"; "widgets__blocks__error" = "Δεν ήταν δυνατή η λήψη δεδομένων blocks"; "widgets__weather__condition__good__title" = "Ευνοϊκές Συνθήκες"; -"widgets__weather__condition__good__description" = "Όλα καλά. Τώρα θα ήταν καλή στιγμή για συναλλαγές στο blockchain."; +"widgets__weather__condition__good__description" = "Τώρα θα ήταν καλή στιγμή για συναλλαγές στο blockchain."; "widgets__weather__condition__average__title" = "Μέσες Συνθήκες"; -"widgets__weather__condition__average__description" = "Το ποσοστό επόμενου block είναι κοντά στους μηνιαίους μέσους όρους."; +"widgets__weather__condition__average__description" = "Το ποσοστό επόμενου block είναι κοντά στους μηνιαίους και ετήσιους μέσους όρους."; "widgets__weather__condition__poor__title" = "Κακές Συνθήκες"; -"widgets__weather__condition__poor__description" = "Αν δεν βιάζεστε να κάνετε συναλλαγές, μπορεί να είναι καλύτερα να περιμένετε λίγο."; +"widgets__weather__condition__poor__description" = "Αν δεν βιάζεστε να κάνετε συναλλαγές, μπορεί να είναι καλύτερα να περιμένετε."; "widgets__weather__current_fee" = "Τρέχον μέσο τέλος"; "widgets__weather__next_block" = "Συμπερίληψη σε επόμενο block"; "widgets__weather__error" = "Δεν ήταν δυνατή η λήψη τρέχοντος καιρού τελών"; +"widgets__weather__condition__good__short_title" = "Ευνοϊκές"; +"widgets__weather__condition__average__short_title" = "Μέσες"; +"widgets__weather__condition__poor__short_title" = "Κακές"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index bb9175fbe..4adc42912 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1401,6 +1401,7 @@ "widgets__price__period_year" = "Year"; "widgets__widget__size_small" = "Small"; "widgets__widget__size_wide" = "Wide"; +"widgets__widget__display" = "Display"; "widgets__widget__settings" = "Widget Settings"; "widgets__widget__save_widget" = "Save Widget"; "widgets__news__name" = "Bitcoin Headlines"; @@ -1420,11 +1421,14 @@ "widgets__weather__name" = "Bitcoin Weather"; "widgets__weather__description" = "Find out when it's a good time to transact on the Bitcoin blockchain."; "widgets__weather__condition__good__title" = "Favorable Conditions"; -"widgets__weather__condition__good__description" = "All clear. Now would be a good time to transact on the blockchain."; +"widgets__weather__condition__good__description" = "Now would be a good time to transact on the blockchain."; "widgets__weather__condition__average__title" = "Average Conditions"; -"widgets__weather__condition__average__description" = "The next block rate is close to the monthly averages."; +"widgets__weather__condition__average__description" = "Next block rate is close to the monthly and yearly averages."; "widgets__weather__condition__poor__title" = "Poor Conditions"; -"widgets__weather__condition__poor__description" = "If you are not in a hurry to transact, it may be better to wait a bit."; -"widgets__weather__current_fee" = "Current average fee"; +"widgets__weather__condition__poor__description" = "If you are not in a hurry to transact, it may be better to wait."; +"widgets__weather__current_fee" = "Current fee"; "widgets__weather__next_block" = "Next block inclusion"; "widgets__weather__error" = "Couldn\'t get current fee weather"; +"widgets__weather__condition__good__short_title" = "Favorable"; +"widgets__weather__condition__average__short_title" = "Average"; +"widgets__weather__condition__poor__short_title" = "Poor"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index c8fe37de4..6b6a61b7b 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1176,11 +1176,14 @@ "widgets__weather__name" = "Clima Bitcoin"; "widgets__weather__description" = "Sepa cuándo es un buen momento para realizar transacciones en la blockchain de Bitcoin."; "widgets__weather__condition__good__title" = "Condiciones favorables"; -"widgets__weather__condition__good__description" = "Todo despejado. Ahora sería un buen momento para realizar transacciones en la blockchain."; +"widgets__weather__condition__good__description" = "Ahora sería un buen momento para realizar transacciones en la blockchain."; "widgets__weather__condition__average__title" = "Condiciones promedio"; -"widgets__weather__condition__average__description" = "La tasa del siguiente bloque se acerca a las medias mensuales."; +"widgets__weather__condition__average__description" = "La tasa del siguiente bloque se acerca a las medias mensuales y anuales."; "widgets__weather__condition__poor__title" = "Malas condiciones"; -"widgets__weather__condition__poor__description" = "Si no tiene prisa por realizar una transacción, quizá sea mejor esperar un poco."; +"widgets__weather__condition__poor__description" = "Si no tiene prisa por realizar una transacción, quizá sea mejor esperar."; "widgets__weather__current_fee" = "Tasa media actual"; "widgets__weather__next_block" = "Inclusión en siguiente bloque"; "widgets__weather__error" = "No se pudo consultar la condición actual de fees"; +"widgets__weather__condition__good__short_title" = "Favorables"; +"widgets__weather__condition__average__short_title" = "Promedio"; +"widgets__weather__condition__poor__short_title" = "Malas"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 7bfc21fa8..98a3f8cd1 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1027,11 +1027,14 @@ "widgets__blocks__error" = "No se pudieron obtener los datos de bloques"; "widgets__facts__name" = "Hechos Bitcoin"; "widgets__weather__condition__good__title" = "Condiciones Favorables"; -"widgets__weather__condition__good__description" = "Todo despejado. Ahora sería un buen momento para realizar transacciones en la blockchain."; +"widgets__weather__condition__good__description" = "Ahora sería un buen momento para realizar transacciones en la blockchain."; "widgets__weather__condition__average__title" = "Condiciones Promedio"; -"widgets__weather__condition__average__description" = "La tasa del próximo bloque está cerca de los promedios mensuales."; +"widgets__weather__condition__average__description" = "La tasa del próximo bloque está cerca de los promedios mensuales y anuales."; "widgets__weather__condition__poor__title" = "Condiciones Desfavorables"; -"widgets__weather__condition__poor__description" = "Si no tienes prisa por hacer transacciones, puede ser mejor esperar un poco."; +"widgets__weather__condition__poor__description" = "Si no tienes prisa por hacer transacciones, puede ser mejor esperar."; "widgets__weather__current_fee" = "Comisión promedio actual"; "widgets__weather__next_block" = "Inclusión en el próximo bloque"; "widgets__weather__error" = "No se pudo obtener el clima de comisiones actual"; +"widgets__weather__condition__good__short_title" = "Favorables"; +"widgets__weather__condition__average__short_title" = "Promedio"; +"widgets__weather__condition__poor__short_title" = "Desfavorables"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index f8012778f..733882182 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1190,11 +1190,14 @@ "widgets__weather__name" = "Météo Bitcoin"; "widgets__weather__description" = "Découvrez quel est le bon moment pour effectuer des transactions sur la blockchain Bitcoin."; "widgets__weather__condition__good__title" = "Conditions favorables"; -"widgets__weather__condition__good__description" = "Tout est clair. C\'est le bon moment pour effectuer des transactions sur la blockchain."; +"widgets__weather__condition__good__description" = "C\'est le bon moment pour effectuer des transactions sur la blockchain."; "widgets__weather__condition__average__title" = "Conditions moyennes"; -"widgets__weather__condition__average__description" = "Les frais du bloc suivant sont proches des moyennes mensuelles."; +"widgets__weather__condition__average__description" = "Les frais du bloc suivant sont proches des moyennes mensuelles et annuelles."; "widgets__weather__condition__poor__title" = "Mauvaises conditions"; -"widgets__weather__condition__poor__description" = "Si vous n\'êtes pas pressé d\'effectuer une transaction, il peut être préférable d\'attendre un peu."; +"widgets__weather__condition__poor__description" = "Si vous n\'êtes pas pressé d\'effectuer une transaction, il peut être préférable d\'attendre."; "widgets__weather__current_fee" = "Frais moyen actuel"; "widgets__weather__next_block" = "Inclusion dans le bloc suivant"; "widgets__weather__error" = "Impossible d'obtenir les frais de transaction actuels"; +"widgets__weather__condition__good__short_title" = "Favorables"; +"widgets__weather__condition__average__short_title" = "Moyennes"; +"widgets__weather__condition__poor__short_title" = "Mauvaises"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index e34a74cec..0b1186efd 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1136,11 +1136,14 @@ "widgets__blocks__error" = "Impossibile ottenere i dati dei blocchi"; "widgets__facts__name" = "Fatti su Bitcoin"; "widgets__weather__condition__good__title" = "Condizioni Favorevoli"; -"widgets__weather__condition__good__description" = "Tutto chiaro. Ora sarebbe un buon momento per transare sulla blockchain."; +"widgets__weather__condition__good__description" = "Ora sarebbe un buon momento per transare sulla blockchain."; "widgets__weather__condition__average__title" = "Condizioni Medie"; -"widgets__weather__condition__average__description" = "La tariffa del prossimo blocco è vicina alle medie mensili."; +"widgets__weather__condition__average__description" = "La tariffa del prossimo blocco è vicina alle medie mensili e annuali."; "widgets__weather__condition__poor__title" = "Condizioni Sfavorevoli"; -"widgets__weather__condition__poor__description" = "Se non hai fretta di transare, potrebbe essere meglio aspettare un po'."; +"widgets__weather__condition__poor__description" = "Se non hai fretta di transare, potrebbe essere meglio aspettare."; "widgets__weather__current_fee" = "Commissione media attuale"; "widgets__weather__next_block" = "Inclusione prossimo blocco"; "widgets__weather__error" = "Impossibile ottenere le condizioni attuali delle commissioni"; +"widgets__weather__condition__good__short_title" = "Favorevoli"; +"widgets__weather__condition__average__short_title" = "Medie"; +"widgets__weather__condition__poor__short_title" = "Sfavorevoli"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 3336e7ca5..f56438151 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1184,11 +1184,14 @@ "widgets__weather__name" = "Bitcoin Wetter"; "widgets__weather__description" = "Finde heraus, wann es eine gute Zeit ist, um auf der Bitcoin blockchain Überweisungen zu tätigen."; "widgets__weather__condition__good__title" = "Gunstige Omstandigheden"; -"widgets__weather__condition__good__description" = "Alles vrij. Nu is een goed moment om op de blockchain te transacteren."; +"widgets__weather__condition__good__description" = "Nu is een goed moment om op de blockchain te transacteren."; "widgets__weather__condition__average__title" = "Gemiddelde Omstandigheden"; -"widgets__weather__condition__average__description" = "Het tarief voor het volgende blok ligt dicht bij het maandelijks gemiddelde."; +"widgets__weather__condition__average__description" = "Het tarief voor het volgende blok ligt dicht bij het maandelijks en jaarlijks gemiddelde."; "widgets__weather__condition__poor__title" = "Slechte Omstandigheden"; -"widgets__weather__condition__poor__description" = "Als u geen haast heeft om te transacteren, is het misschien beter om even te wachten."; +"widgets__weather__condition__poor__description" = "Als u geen haast heeft om te transacteren, is het misschien beter om te wachten."; "widgets__weather__current_fee" = "Huidige gemiddelde vergoeding"; "widgets__weather__next_block" = "Opname in volgend blok"; "widgets__weather__error" = "Kon huidige vergoedingsweer niet ophalen"; +"widgets__weather__condition__good__short_title" = "Gunstig"; +"widgets__weather__condition__average__short_title" = "Gemiddeld"; +"widgets__weather__condition__poor__short_title" = "Slecht"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 7f32e6a0d..c76dd7f05 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1191,11 +1191,14 @@ "widgets__weather__name" = "Bitcoinowa Pogoda"; "widgets__weather__description" = "Sprawdź, kiedy jest dobry moment na transakcję w sieci Bitcoin."; "widgets__weather__condition__good__title" = "Korzystne warunki"; -"widgets__weather__condition__good__description" = "Brak przeszkód. Teraz jest dobry moment na transakcję w sieci blockchain."; +"widgets__weather__condition__good__description" = "Teraz jest dobry moment na transakcję w sieci blockchain."; "widgets__weather__condition__average__title" = "Średnie warunki"; -"widgets__weather__condition__average__description" = "Opłaty za następny blok są zbliżone do miesięcznych średnich."; +"widgets__weather__condition__average__description" = "Opłaty za następny blok są zbliżone do miesięcznych i rocznych średnich."; "widgets__weather__condition__poor__title" = "Niekorzystne warunki"; -"widgets__weather__condition__poor__description" = "Jeśli nie musisz się spieszyć z transakcją, lepiej chwilę poczekać."; +"widgets__weather__condition__poor__description" = "Jeśli nie musisz się spieszyć z transakcją, lepiej poczekać."; "widgets__weather__current_fee" = "Obecna średnia opłata"; "widgets__weather__next_block" = "Włączenie do następnego bloku"; "widgets__weather__error" = "Nie udało się pobrać aktualnych danych o opłatach"; +"widgets__weather__condition__good__short_title" = "Korzystne"; +"widgets__weather__condition__average__short_title" = "Średnie"; +"widgets__weather__condition__poor__short_title" = "Niekorzystne"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 8bb6e73a8..b3197bebe 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1192,11 +1192,14 @@ "widgets__weather__name" = "Tempo do Bitcoin"; "widgets__weather__description" = "Descubra quando é um bom momento para fazer transações na blockchain do Bitcoin."; "widgets__weather__condition__good__title" = "Condições favoráveis"; -"widgets__weather__condition__good__description" = "Tudo limpo. Agora seria um bom momento para fazer transações na blockchain."; +"widgets__weather__condition__good__description" = "Agora seria um bom momento para fazer transações na blockchain."; "widgets__weather__condition__average__title" = "Condições normais"; -"widgets__weather__condition__average__description" = "A taxa do próximo bloco está próxima das médias mensais."; +"widgets__weather__condition__average__description" = "A taxa do próximo bloco está próxima das médias mensais e anuais."; "widgets__weather__condition__poor__title" = "Condições ruins"; -"widgets__weather__condition__poor__description" = "Se você não estiver com pressa para fazer uma transação, talvez seja melhor esperar um pouco."; -"widgets__weather__current_fee" = "Tarifa média atual"; +"widgets__weather__condition__poor__description" = "Se você não estiver com pressa para fazer uma transação, talvez seja melhor esperar."; +"widgets__weather__current_fee" = "Taxa atual"; "widgets__weather__next_block" = "Inclusão no próximo bloco"; "widgets__weather__error" = "Não foi possível obter o tempo atual da taxa"; +"widgets__weather__condition__good__short_title" = "Favoráveis"; +"widgets__weather__condition__average__short_title" = "Normais"; +"widgets__weather__condition__poor__short_title" = "Ruins"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 8645a32a1..3a4aa86e1 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1170,11 +1170,14 @@ "widgets__blocks__error" = "Не удалось получить данные о блоках"; "widgets__facts__name" = "Биткоин Факты"; "widgets__weather__condition__good__title" = "Благоприятные Условия"; -"widgets__weather__condition__good__description" = "Всё хорошо. Сейчас подходящее время для транзакций в блокчейне."; +"widgets__weather__condition__good__description" = "Сейчас подходящее время для транзакций в блокчейне."; "widgets__weather__condition__average__title" = "Средние Условия"; -"widgets__weather__condition__average__description" = "Ставка следующего блока близка к средним месячным показателям."; +"widgets__weather__condition__average__description" = "Ставка следующего блока близка к средним месячным и годовым показателям."; "widgets__weather__condition__poor__title" = "Плохие Условия"; -"widgets__weather__condition__poor__description" = "Если вы не спешите с транзакцией, возможно, лучше немного подождать."; +"widgets__weather__condition__poor__description" = "Если вы не спешите с транзакцией, возможно, лучше подождать."; "widgets__weather__current_fee" = "Текущая средняя комиссия"; "widgets__weather__next_block" = "Включение в следующий блок"; "widgets__weather__error" = "Не удалось получить данные о погоде комиссий"; +"widgets__weather__condition__good__short_title" = "Благоприятные"; +"widgets__weather__condition__average__short_title" = "Средние"; +"widgets__weather__condition__poor__short_title" = "Плохие"; diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index c3a2dd786..0b931f438 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1,6 +1,7 @@ import BitkitCore import Foundation import Security +import WidgetKit // MARK: - MMKV Parser @@ -310,6 +311,29 @@ class MigrationsService: ObservableObject { private let fileManager = FileManager.default + private static let appGroupSuiteName = "group.bitkit" + private static let appGroupCurrencyKey = "home_screen_display_currency_code_v1" + private static let appGroupLanguageKey = "selectedLanguageCode" + private static let weatherWidgetKind = "BitkitWeatherWidget" + + /// Mirrors the migrated display currency into the App Group and pokes the weather widget + /// timeline. Inlined here (rather than calling `WeatherCurrencyAppGroupStore` / + /// `WeatherHomeScreenWidgetOptionsStore`) because this file is also compiled into the + /// `BitkitTests` target, which doesn't pick up those helper files. Keys must match the + /// canonical helpers. + static func mirrorCurrencyToAppGroup(code: String) { + UserDefaults(suiteName: appGroupSuiteName)?.set(code, forKey: appGroupCurrencyKey) + WidgetCenter.shared.reloadTimelines(ofKind: weatherWidgetKind) + } + + /// Mirrors the migrated language code into the App Group and reloads all widget timelines. + /// Same coupling-avoidance rationale as `mirrorCurrencyToAppGroup` above; the equivalent + /// canonical helper is `LanguageManager.mirrorToAppGroup(code:)`. + static func mirrorLanguageToAppGroup(code: String) { + UserDefaults(suiteName: appGroupSuiteName)?.set(code, forKey: appGroupLanguageKey) + WidgetCenter.shared.reloadAllTimelines() + } + private static let rnMigrationCompletedKey = "rnMigrationCompleted" private static let rnMigrationCheckedKey = "rnMigrationChecked" private static let rnNeedsPostMigrationSyncKey = "rnNeedsPostMigrationSync" @@ -1187,9 +1211,11 @@ extension MigrationsService { if let currency = settings.selectedCurrency { defaults.set(currency, forKey: "selectedCurrency") + Self.mirrorCurrencyToAppGroup(code: currency) } if let language = settings.selectedLanguage { defaults.set(language, forKey: "selectedLanguageCode") + Self.mirrorLanguageToAppGroup(code: language) } if let unit = settings.unit { let nativeValue = unit == "BTC" ? "Bitcoin" : "Fiat" diff --git a/Bitkit/Services/Widgets/MempoolWeatherAPI.swift b/Bitkit/Services/Widgets/MempoolWeatherAPI.swift new file mode 100644 index 000000000..7f9a051b3 --- /dev/null +++ b/Bitkit/Services/Widgets/MempoolWeatherAPI.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Pure HTTP layer for the mempool.space endpoints consumed by both the main app +/// (`WeatherService`) and the WidgetKit extension (`WeatherWidgetService`). Keeping the URL +/// strings, wire models, and decoding in one place avoids drift between the two targets. +enum MempoolWeatherAPI { + enum APIError: Error { + case invalidURL + case unexpectedResponse + } + + private static let baseUrl = "https://mempool.space/api/v1" + + // MARK: - Endpoints + + /// `/v1/fees/recommended` — current recommended fee rates in sats/vByte. + static func fetchRecommendedFees() async throws -> RecommendedFees { + try await get(path: "fees/recommended") + } + + /// `/v1/prices` — BTC spot price map (currency code → unit price for 1 BTC). Mempool also + /// returns a `time` field which is filtered out here so the returned dictionary only + /// contains fiat amounts. + static func fetchPrices() async throws -> [String: Double] { + let raw: [String: Double] = try await get(path: "prices") + return raw.filter { $0.key != "time" } + } + + /// `/v1/mining/blocks/fee-rates/3m` — last 3 months of per-block fee summaries used to + /// derive the percentile thresholds in `FeePercentile`. + static func fetchHistoricalFees() async throws -> [BlockFeeRates] { + try await get(path: "mining/blocks/fee-rates/3m") + } + + // MARK: - HTTP helper + + private static func get(path: String) async throws -> T { + guard let url = URL(string: "\(baseUrl)/\(path)") else { + throw APIError.invalidURL + } + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw APIError.unexpectedResponse + } + return try JSONDecoder().decode(T.self, from: data) + } +} + +// MARK: - Wire models + +/// Decoded shape of `/v1/fees/recommended`. All values are sats/vByte. +struct RecommendedFees: Codable { + let fastestFee: Int + let halfHourFee: Int + let hourFee: Int + let economyFee: Int + let minimumFee: Int +} diff --git a/Bitkit/Services/Widgets/WeatherHomeScreenWidgetOptionsStore.swift b/Bitkit/Services/Widgets/WeatherHomeScreenWidgetOptionsStore.swift new file mode 100644 index 000000000..8ebe0567e --- /dev/null +++ b/Bitkit/Services/Widgets/WeatherHomeScreenWidgetOptionsStore.swift @@ -0,0 +1,56 @@ +import Foundation +import WidgetKit + +/// Mirrors in-app Weather widget options into the App Group so the WidgetKit extension can read them, +/// and centralizes the WidgetKit reload trigger for the Weather home-screen widget. +enum WeatherHomeScreenWidgetOptionsStore { + static let weatherHomeScreenWidgetKind = "BitkitWeatherWidget" + + private static let suiteName = "group.bitkit" + private static let key = "home_screen_weather_widget_options_v1" + + static func save(_ options: WeatherWidgetOptions) { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = try? JSONEncoder().encode(options) + else { return } + defaults.set(data, forKey: key) + } + + static func load() -> WeatherWidgetOptions { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: key), + let options = try? JSONDecoder().decode(WeatherWidgetOptions.self, from: data) + else { + return WeatherWidgetOptions() + } + return options + } + + /// Call after updating options or cache so the home-screen widget timeline refreshes. + /// No-op when running inside the widget extension itself (`appex`). + static func reloadHomeScreenWidgetIfNeeded() { + guard Bundle.main.bundleURL.pathExtension != "appex" else { return } + WidgetCenter.shared.reloadTimelines(ofKind: weatherHomeScreenWidgetKind) + } +} + +enum WeatherCurrencyAppGroupStore { + private static let suiteName = "group.bitkit" + private static let key = "home_screen_display_currency_code_v1" + static let fallbackCode = "USD" + + static func save(code: String) { + guard let defaults = UserDefaults(suiteName: suiteName) else { return } + defaults.set(code, forKey: key) + } + + static func load() -> String { + guard let defaults = UserDefaults(suiteName: suiteName), + let code = defaults.string(forKey: key), + !code.isEmpty + else { + return fallbackCode + } + return code + } +} diff --git a/Bitkit/Services/Widgets/WeatherService.swift b/Bitkit/Services/Widgets/WeatherService.swift index fa374c7a3..646d1cb55 100644 --- a/Bitkit/Services/Widgets/WeatherService.swift +++ b/Bitkit/Services/Widgets/WeatherService.swift @@ -7,27 +7,16 @@ struct WeatherServiceResponse { let fees: FeeRates } -/// Historical fee percentile information -struct FeePercentile { - let lowThreshold: Double // 33rd percentile - let highThreshold: Double // 66th percentile -} - -/// Service for fetching and caching Bitcoin fee weather data +/// Service for fetching and caching Bitcoin fee weather data. class WeatherService { static let shared = WeatherService() - private let cache = UserDefaults.standard - private let cacheKey = "weather_widget_cache" - private let baseUrl = "https://mempool.space/api/v1" private let refreshInterval: TimeInterval = 2 * 60 // 2 minutes - private let percentileLow = 0.33 - private let percentileHigh = 0.66 - private let coreService: CoreService private init() { coreService = CoreService.shared + WeatherWidgetCache.legacyDropStandardSuiteCache() } /// Fetches weather data from mempool.space API and fee estimates @@ -36,11 +25,16 @@ class WeatherService { func fetchWeatherData() async throws -> WeatherServiceResponse { // Fetch fee estimates and historical data concurrently async let feeEstimates = fetchFeeEstimates() - async let historicalData = fetchHistoricalData() + async let historicalData = MempoolWeatherAPI.fetchHistoricalFees() let (fees, history) = try await (feeEstimates, historicalData) - let percentile = try calculatePercentileThresholds(history: history) + guard let percentile = FeePercentile(history: history) else { + throw URLError( + .resourceUnavailable, + userInfo: [NSLocalizedDescriptionKey: "Historical fee data is unavailable"] + ) + } return WeatherServiceResponse( historicalPercentile: percentile, @@ -48,31 +42,14 @@ class WeatherService { ) } - /// Caches weather data to UserDefaults - /// - Parameter data: Weather data to cache - func cacheData(_ data: WeatherData) { - do { - let encoder = JSONEncoder() - let encoded = try encoder.encode(data) - cache.set(encoded, forKey: cacheKey) - } catch { - // Handle silently - } + /// Caches weather data to the App Group so the WidgetKit extension can read it. + func cacheData(_ data: CachedWeather) { + WeatherWidgetCache.saveLatest(data) } - /// Retrieves cached weather data - /// - Returns: Weather data if available - func getCachedData() -> WeatherData? { - guard let data = cache.data(forKey: cacheKey) else { - return nil - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(WeatherData.self, from: data) - } catch { - return nil - } + /// Retrieves cached weather data from the App Group. + func getCachedData() -> CachedWeather? { + WeatherWidgetCache.loadLatest() } // MARK: - Private Methods @@ -91,41 +68,4 @@ class WeatherService { return fees } - - /// Fetches historical fee data from mempool.space - private func fetchHistoricalData() async throws -> [BlockFeeRates] { - guard let url = URL(string: "\(baseUrl)/mining/blocks/fee-rates/3m") else { - throw URLError(.badURL) - } - - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - throw URLError(.badServerResponse) - } - - return try JSONDecoder().decode([BlockFeeRates].self, from: data) - } - - /// Calculates percentile thresholds from historical data - private func calculatePercentileThresholds(history: [BlockFeeRates]) throws -> FeePercentile { - guard !history.isEmpty else { - throw URLError( - .resourceUnavailable, - userInfo: [ - NSLocalizedDescriptionKey: "Historical fee data is unavailable", - ] - ) - } - - let historical = history.map(\.avgFee_50) - let sorted = historical.sorted() - - let lowThreshold = sorted[Int(Double(sorted.count) * percentileLow)] - let highThreshold = sorted[Int(Double(sorted.count) * percentileHigh)] - - return FeePercentile(lowThreshold: lowThreshold, highThreshold: highThreshold) - } } diff --git a/Bitkit/Utilities/LocalizeHelpers.swift b/Bitkit/Utilities/LocalizeHelpers.swift index b5c4b325f..45275b642 100644 --- a/Bitkit/Utilities/LocalizeHelpers.swift +++ b/Bitkit/Utilities/LocalizeHelpers.swift @@ -3,12 +3,17 @@ import Foundation /// Centralized localization helper with English fallback support enum LocalizationHelper { private static let notFoundValue = "___NOTFOUND___" + private static let appGroupSuiteName = "group.bitkit" + private static let selectedLanguageCodeKey = "selectedLanguageCode" - /// Gets the current language code from user preferences private static var currentLanguageCode: String { - let storedCode = UserDefaults.standard.string(forKey: "selectedLanguageCode") ?? "" - let deviceLanguageCode = Locale.current.language.languageCode?.identifier ?? "en" - return storedCode.isEmpty ? deviceLanguageCode : storedCode + let appGroupCode = UserDefaults(suiteName: appGroupSuiteName)?.string(forKey: selectedLanguageCodeKey) ?? "" + if !appGroupCode.isEmpty { return appGroupCode } + + let standardCode = UserDefaults.standard.string(forKey: selectedLanguageCodeKey) ?? "" + if !standardCode.isEmpty { return standardCode } + + return Locale.current.language.languageCode?.identifier ?? "en" } /// Checks if a localization key exists in a specific bundle @@ -31,7 +36,6 @@ enum LocalizationHelper { return getStringFromBundle(englishBundle, key: key, comment: comment) } - // Try selected language first, fallback to English if key missing if keyExists(in: selectedBundle, key: key) { return NSLocalizedString(key, bundle: selectedBundle, comment: comment) } else { diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 706e6660c..75b18d088 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -43,12 +43,7 @@ enum WidgetsBackupConverter { } case .weather: if let options = try? JSONDecoder().decode(WeatherWidgetOptions.self, from: optionsData) { - weatherPreferences = [ - "showTitle": options.showStatus, - "showDescription": options.showText, - "showCurrentFee": options.showMedian, - "showNextBlockFee": options.showNextBlockFee, - ] + weatherPreferences = androidWeatherPreferences(for: options.selectedMetric) } case .price: if let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: optionsData) { @@ -135,10 +130,7 @@ enum WidgetsBackupConverter { case .weather: if let prefs = jsonDict["weatherPreferences"] as? [String: Any] { let iosOptions = WeatherWidgetOptions( - showStatus: prefs["showTitle"] as? Bool ?? true, - showText: prefs["showDescription"] as? Bool ?? false, - showMedian: prefs["showCurrentFee"] as? Bool ?? false, - showNextBlockFee: prefs["showNextBlockFee"] as? Bool ?? false + selectedMetric: weatherMetric(fromAndroidPrefs: prefs) ) optionsData = try? JSONEncoder().encode(iosOptions) } @@ -196,12 +188,41 @@ enum WidgetsBackupConverter { private static func getDefaultWeatherPreferences() -> [String: Any] { let defaults = WeatherWidgetOptions() - return [ - "showTitle": defaults.showStatus, - "showDescription": defaults.showText, - "showCurrentFee": defaults.showMedian, - "showNextBlockFee": defaults.showNextBlockFee, - ] + return androidWeatherPreferences(for: defaults.selectedMetric) + } + + private static func androidWeatherPreferences(for metric: WeatherDisplayMetric) -> [String: Any] { + ["selectedOption": androidSelectedOption(for: metric)] + } + + private static func androidSelectedOption(for metric: WeatherDisplayMetric) -> String { + switch metric { + case .fiatFee: return "CURRENT_FEE_FIAT" + case .satsFee: return "CURRENT_FEE_SATS" + case .nextBlockFee: return "NEXT_BLOCK_INCLUSION" + } + } + + /// Maps Android `WeatherPreferences` to the v61 iOS metric. Prefers the v61 + /// `selectedOption` enum string; falls back to the legacy 4-toggle shape for backups + /// produced by older builds. + private static func weatherMetric(fromAndroidPrefs prefs: [String: Any]) -> WeatherDisplayMetric { + if let selected = prefs["selectedOption"] as? String { + switch selected { + case "CURRENT_FEE_FIAT": return .fiatFee + case "CURRENT_FEE_SATS": return .satsFee + case "NEXT_BLOCK_INCLUSION": return .nextBlockFee + default: break + } + } + + // Legacy fallback: older builds stored four booleans instead of an enum. + let showCurrentFee = prefs["showCurrentFee"] as? Bool ?? false + let showNextBlockFee = prefs["showNextBlockFee"] as? Bool ?? false + if showNextBlockFee && !showCurrentFee { + return .nextBlockFee + } + return .fiatFee } private static func getDefaultPricePreferences() -> [String: Any] { diff --git a/Bitkit/ViewModels/CurrencyViewModel.swift b/Bitkit/ViewModels/CurrencyViewModel.swift index bf2d0ee37..5e7386f88 100644 --- a/Bitkit/ViewModels/CurrencyViewModel.swift +++ b/Bitkit/ViewModels/CurrencyViewModel.swift @@ -12,7 +12,28 @@ class CurrencyViewModel: ObservableObject { @Published private(set) var error: Error? @Published private(set) var hasStaleData: Bool = false @Published private(set) var isRefreshing = false - @AppStorage("selectedCurrency") var selectedCurrency: String = "USD" + + private static let selectedCurrencyKey = "selectedCurrency" + + /// User's display currency. Backed by `UserDefaults.standard` (preserves existing + /// `@AppStorage` semantics) and mirrored into the App Group so the WidgetKit extension + /// can format its own fiat strings on cold-fetch fallbacks. + var selectedCurrency: String { + get { + UserDefaults.standard.string(forKey: Self.selectedCurrencyKey) ?? "USD" + } + set { + guard newValue != selectedCurrency else { return } + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: Self.selectedCurrencyKey) + WeatherCurrencyAppGroupStore.save(code: newValue) + + WeatherViewModel.shared.setCurrencyViewModel(self) + WeatherViewModel.shared.handleCurrencyChange() + WeatherHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } + } + @AppStorage("bitcoinDisplayUnit") var displayUnit: BitcoinDisplayUnit = .modern @AppStorage("primaryDisplay") var primaryDisplay: PrimaryDisplay = .bitcoin @@ -29,6 +50,9 @@ class CurrencyViewModel: ObservableObject { rates = cachedRates } + // Ensure the widget extension sees the current display currency on first launch. + WeatherCurrencyAppGroupStore.save(code: selectedCurrency) + startPolling() } diff --git a/Bitkit/ViewModels/Widgets/WeatherViewModel.swift b/Bitkit/ViewModels/Widgets/WeatherViewModel.swift index b005a9dd9..5f57868a0 100644 --- a/Bitkit/ViewModels/Widgets/WeatherViewModel.swift +++ b/Bitkit/ViewModels/Widgets/WeatherViewModel.swift @@ -1,25 +1,12 @@ import Foundation import SwiftUI -/// Block fee rates structure from mempool.space API -struct BlockFeeRates: Codable { - let avgHeight: Int - let timestamp: Int - let avgFee_0: Double - let avgFee_10: Double - let avgFee_25: Double - let avgFee_50: Double - let avgFee_75: Double - let avgFee_90: Double - let avgFee_100: Double -} - /// Weather widget view model for handling fee weather data @MainActor class WeatherViewModel: ObservableObject { static let shared = WeatherViewModel() - @Published var weatherData: WeatherData? + @Published var weatherData: CachedWeather? @Published var isLoading: Bool = false @Published var error: Error? @@ -43,6 +30,24 @@ class WeatherViewModel: ObservableObject { self.currencyViewModel = currencyViewModel } + func handleCurrencyChange() { + guard let cached = weatherData ?? weatherService.getCachedData() else { return } + + guard let reformatted = try? formatFeeAmount(cached.currentFeeSats) else { + WeatherWidgetCache.invalidateFreshness() + return + } + + let updated = CachedWeather( + condition: cached.condition, + currentFeeFiat: reformatted, + currentFeeSats: cached.currentFeeSats, + nextBlockFee: cached.nextBlockFee + ) + weatherData = updated + weatherService.cacheData(updated) + } + /// Start loading data and periodic updates (idempotent - only starts once) func startUpdates() { guard !hasStartedUpdates else { return } @@ -106,56 +111,47 @@ class WeatherViewModel: ObservableObject { /// Fetches fresh weather data from API (always hits the network) @discardableResult - private func fetchFreshWeatherData() async throws -> WeatherData { + private func fetchFreshWeatherData() async throws -> CachedWeather { let response = try await weatherService.fetchWeatherData() - // Calculate condition using USD threshold logic - let condition = calculateCondition( - feeRate: Double(response.fees.mid), + let midSatsPerVbyte = Double(response.fees.mid) + let medianFeeSats = Int(response.fees.mid) * vbytesSize + + let condition = FeeCondition.evaluate( + midSatsPerVbyte: midSatsPerVbyte, + totalSats: medianFeeSats, + usdPerBtc: usdPerBtcRate(), percentile: response.historicalPercentile ) - let avgFee = Int(response.fees.mid) * vbytesSize - let formattedFee = try formatFeeAmount(avgFee) + let formattedFiat = try formatFeeAmount(medianFeeSats) - let data = WeatherData( + let data = CachedWeather( condition: condition, - currentFee: formattedFee, + currentFeeFiat: formattedFiat, + currentFeeSats: medianFeeSats, nextBlockFee: Int(response.fees.fast) ) weatherService.cacheData(data) + WeatherWidgetCache.savePercentile(response.historicalPercentile) + WeatherHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() weatherData = data error = nil return data } - /// Calculates fee condition using USD threshold and historical percentiles - private func calculateCondition( - feeRate: Double, - percentile: FeePercentile - ) -> FeeCondition { - // Constants for condition calculation - let usdGoodThreshold = Decimal(1.0) // $1 USD threshold for good condition - - // Check USD threshold first using currency conversion - if let currencyViewModel, - let converted = currencyViewModel.convert(sats: UInt64(feeRate), to: "USD") - { - if converted.value <= usdGoodThreshold { - return .good - } - } - - // Determine status based on current fee relative to percentiles - if feeRate <= percentile.lowThreshold { - return .good - } else if feeRate >= percentile.highThreshold { - return .poor - } else { - return .average + /// Derives BTC/USD spot price from the injected `CurrencyViewModel` by converting 1 BTC to + /// USD. Returns `nil` if conversion is unavailable so callers can fall back to the + /// percentile-only branch in `FeeCondition.evaluate`. + private func usdPerBtcRate() -> Double? { + guard let currencyViewModel, + let converted = currencyViewModel.convert(sats: 100_000_000, to: "USD") + else { + return nil } + return NSDecimalNumber(decimal: converted.value).doubleValue } /// Formats fee amount using CurrencyViewModel - throws error if conversion fails diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 31b3929c0..afd9e59ed 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -248,6 +248,8 @@ class WidgetsViewModel: ObservableObject { savedWidgetsWithOptions.append(SavedWidget(type: type, optionsData: optionsData)) } + // Keep the @Published mirror in lockstep so other callers see a consistent picture. + savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() if type == .price, let priceOptions = options as? PriceWidgetOptions { @@ -261,6 +263,10 @@ class WidgetsViewModel: ObservableObject { if type == .blocks, let blocksOptions = options as? BlocksWidgetOptions { syncBlocksOptionsToHomeScreenWidget(blocksOptions) } + + if type == .weather, let weatherOptions = options as? WeatherWidgetOptions { + syncWeatherOptionsToHomeScreenWidget(weatherOptions) + } } catch { print("Failed to save widget options: \(error)") } @@ -296,8 +302,12 @@ class WidgetsViewModel: ObservableObject { let widgetsData = UserDefaults.standard.data(forKey: Self.savedWidgetsKey) ?? .init() do { - savedWidgetsWithOptions = try JSONDecoder().decode([SavedWidget].self, from: widgetsData) - savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } + let decoded = try JSONDecoder().decode([SavedWidget].self, from: widgetsData) + let deduped = Self.dedupedByType(decoded) + savedWidgetsWithOptions = deduped + savedWidgets = deduped.map { $0.toWidget() } + // If we removed duplicates, rewrite the blob so the bad state disappears permanently. + if deduped.count != decoded.count { persistSavedWidgets() } } catch { // If no saved data or decode fails, start with default widgets savedWidgetsWithOptions = WidgetsViewModel.defaultSavedWidgets @@ -306,6 +316,28 @@ class WidgetsViewModel: ObservableObject { } } + /// Collapses `widgets` to at most one entry per `WidgetType`, preserving the first-seen order. + /// When the input contains duplicates, prefers the entry that carries `optionsData` so the + /// user's customisation isn't lost — within duplicates, the first non-nil `optionsData` wins. + static func dedupedByType(_ widgets: [SavedWidget]) -> [SavedWidget] { + var preferredByType: [WidgetType: SavedWidget] = [:] + var order: [WidgetType] = [] + for widget in widgets { + if preferredByType[widget.type] == nil { + preferredByType[widget.type] = widget + order.append(widget.type) + continue + } + if let existing = preferredByType[widget.type], + existing.optionsData == nil, + widget.optionsData != nil + { + preferredByType[widget.type] = widget + } + } + return order.compactMap { preferredByType[$0] } + } + private func persistSavedWidgets() { do { let encodedData = try JSONEncoder().encode(savedWidgetsWithOptions) @@ -315,27 +347,23 @@ class WidgetsViewModel: ObservableObject { } } - /// Mirrors in-app price widget options to the App Group so the home-screen WidgetKit widget can read them. - /// Only invoked when the user explicitly changes price widget options — adding, deleting, or resetting - /// in-app widgets must not affect the independent OS home-screen widget. private func syncPriceOptionsToHomeScreenWidget(_ options: PriceWidgetOptions) { PriceHomeScreenWidgetOptionsStore.save(options) PriceHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } - /// Mirrors in-app news widget options to the App Group so the home-screen WidgetKit widget can read them. - /// Only invoked when the user explicitly changes news widget options — adding, deleting, or resetting - /// in-app widgets must not affect the independent OS home-screen widget. private func syncNewsOptionsToHomeScreenWidget(_ options: NewsWidgetOptions) { NewsHomeScreenWidgetOptionsStore.save(options) NewsHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } - /// Mirrors in-app blocks widget options to the App Group so the home-screen WidgetKit widget can read them. - /// Only invoked when the user explicitly changes blocks widget options — adding, deleting, or resetting - /// in-app widgets must not affect the independent OS home-screen widget. private func syncBlocksOptionsToHomeScreenWidget(_ options: BlocksWidgetOptions) { BlocksHomeScreenWidgetOptionsStore.save(options) BlocksHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() } + + private func syncWeatherOptionsToHomeScreenWidget(_ options: WeatherWidgetOptions) { + WeatherHomeScreenWidgetOptionsStore.save(options) + WeatherHomeScreenWidgetOptionsStore.reloadHomeScreenWidgetIfNeeded() + } } diff --git a/Bitkit/Views/Widgets/WeatherWidgetPreviewView.swift b/Bitkit/Views/Widgets/WeatherWidgetPreviewView.swift new file mode 100644 index 000000000..648af44ca --- /dev/null +++ b/Bitkit/Views/Widgets/WeatherWidgetPreviewView.swift @@ -0,0 +1,260 @@ +import SwiftUI + +/// Preview screen for the Bitcoin Weather widget. +struct WeatherWidgetPreviewView: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + @EnvironmentObject private var currency: CurrencyViewModel + + @StateObject private var viewModel = WeatherViewModel.shared + + @State private var carouselPage: Int = 0 + @State private var showDeleteAlert = false + + private let widgetType: WidgetType = .weather + + private var widgetName: String { + t("widgets__weather__name") + } + + private var widgetDescription: String { + t("widgets__weather__description") + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(widgetType) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: widgetType) + } + + private var currentOptions: WeatherWidgetOptions { + widgets.getOptions(for: widgetType, as: WeatherWidgetOptions.self) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + NavigationBar(title: widgetName, showMenuButton: false) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(widgetDescription, textColor: .textSecondary) + .padding(.bottom, 16) + + Divider().background(Color.white.opacity(0.1)) + + widgetSettingsRow + + Divider().background(Color.white.opacity(0.1)) + } + + VStack(spacing: 16) { + carousel + + sizeLabel + + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .task { + viewModel.setCurrencyViewModel(currency) + viewModel.startUpdates() + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": widgetName])) + } + ) + } + + // MARK: - Widget Settings cell + + private var widgetSettingsRow: some View { + Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + compactPage.tag(0) + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var compactPage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.weatherData { + WeatherWidgetCompactContent( + data: data, + metric: currentOptions.selectedMetric, + conditionTitle: t(data.condition.shortTitleKey), + metricLabel: t(currentOptions.selectedMetric.labelKey) + ) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderCompact + } + } + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + Group { + if let data = viewModel.weatherData { + WeatherWidgetWideContent( + data: data, + metric: currentOptions.selectedMetric, + conditionTitle: t(data.condition.titleKey), + conditionDescription: t(data.condition.descriptionKey), + metricLabel: t(currentOptions.selectedMetric.labelKey) + ) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + placeholderWide + } + } + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + private var placeholderCompact: some View { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + + private var placeholderWide: some View { + Color.gray6 + .cornerRadius(16) + .frame(height: 180) + .overlay(ProgressView()) + } + + // MARK: - Size label & page indicator + + private var sizeLabel: some View { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + + private var pageIndicator: some View { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(widgetType) + navigation.reset() + } + + private func onDelete() { + widgets.deleteWidget(widgetType) + navigation.reset() + } +} + +#Preview { + NavigationStack { + WeatherWidgetPreviewView() + .environmentObject(NavigationViewModel()) + .environmentObject(WidgetsViewModel()) + .environmentObject(CurrencyViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 7e50e2f14..c2a755f0c 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -42,8 +42,8 @@ class WidgetEditLogic: ObservableObject { case .news: return newsOptions.showTitle || newsOptions.showSource || newsOptions.showDate case .weather: - // Weather widget has multiple options, check if any are enabled - return weatherOptions.showStatus || weatherOptions.showText || weatherOptions.showMedian || weatherOptions.showNextBlockFee + // Weather widget always has a selected metric (single-select), so it always has an enabled option. + return true case .price: // Price widget always has a selected pair (single-select). return true @@ -113,17 +113,8 @@ class WidgetEditLogic: ObservableObject { break } case .weather: - switch item.key { - case "showStatus": - weatherOptions.showStatus.toggle() - case "showText": - weatherOptions.showText.toggle() - case "showMedian": - weatherOptions.showMedian.toggle() - case "showNextBlockFee": - weatherOptions.showNextBlockFee.toggle() - default: - break + if let metric = WeatherDisplayMetric(rawValue: item.key) { + weatherOptions.selectedMetric = metric } case .price: switch item.key { diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index cffbb22cd..b57c7c059 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -249,85 +249,35 @@ enum WidgetEditItemFactory { ) -> [WidgetEditItem] { var items: [WidgetEditItem] = [] - if let data = weatherViewModel.weatherData { - items.append( - WidgetEditItem( - key: "showStatus", - type: .toggleItem, - titleView: AnyView(TitleText(data.condition.title)), - valueView: AnyView(Text(data.condition.icon).font(.system(size: 52))), - isChecked: weatherOptions.showStatus - ) - ) + items.append(sectionHeaderItem(key: "weather_display_header", title: t("widgets__widget__display"))) - items.append( - WidgetEditItem( - key: "showText", - type: .toggleItem, - titleView: AnyView(BodyMText(data.condition.description, textColor: .textPrimary)), - valueView: nil, - isChecked: weatherOptions.showText - ) - ) + let data = weatherViewModel.weatherData - items.append( - WidgetEditItem( - key: "showMedian", - type: .toggleItem, - title: t("widgets__weather__current_fee"), - value: data.currentFee, - isChecked: weatherOptions.showMedian - ) - ) + for metric in WeatherDisplayMetric.allCases { + let isSelected = weatherOptions.selectedMetric == metric + let value = data.map { metric.value(from: $0) } ?? metric.fallbackPreviewValue + let labelText = t(metric.labelKey) - items.append( - WidgetEditItem( - key: "showNextBlockFee", - type: .toggleItem, - title: t("widgets__weather__next_block"), - value: "\(data.nextBlockFee) ₿/vByte", - isChecked: weatherOptions.showNextBlockFee - ) - ) - } else { - // Fallback when no data is available - items.append( - WidgetEditItem( - key: "showStatus", - type: .toggleItem, - titleView: AnyView(TitleText("Good")), - valueView: AnyView(Text("☀️").font(.system(size: 30))), - isChecked: weatherOptions.showStatus - ) + let titleView = AnyView( + VStack(alignment: .leading, spacing: 4) { + CaptionMText(labelText, textColor: .textSecondary) + .textCase(.uppercase) + Text(value) + .font(Fonts.bold(size: 30)) + .foregroundColor(.greenAccent) + .kerning(-1) + .lineLimit(1) + .minimumScaleFactor(0.6) + } ) items.append( WidgetEditItem( - key: "showText", - type: .toggleItem, - titleView: AnyView(BodyMText("Fees are low and transactions are fast", textColor: .textPrimary)), + key: metric.rawValue, + type: .radioItem, + titleView: titleView, valueView: nil, - isChecked: weatherOptions.showText - ) - ) - - items.append( - WidgetEditItem( - key: "showMedian", - type: .toggleItem, - title: t("widgets__weather__current_fee"), - value: "$0.50", - isChecked: weatherOptions.showMedian - ) - ) - - items.append( - WidgetEditItem( - key: "showNextBlockFee", - type: .toggleItem, - title: t("widgets__weather__next_block"), - value: "15 ₿/vByte", - isChecked: weatherOptions.showNextBlockFee + isChecked: isSelected ) ) } diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift index b07374d4a..62203d59d 100644 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ b/Bitkit/Views/Widgets/WidgetEditView.swift @@ -52,10 +52,10 @@ struct WidgetEditView: View { editLogic?.resetOptions() } - /// v61 widget configuration screens (Price, News, Blocks) use the widget name as the title + /// v61 widget configuration screens (Price, News, Blocks, Weather) use the widget name as the title /// and skip the legacy description block. private var usesV61Header: Bool { - id == .price || id == .blocks + id == .price || id == .blocks || id == .weather } var body: some View { diff --git a/BitkitTests/WeatherConditionTests.swift b/BitkitTests/WeatherConditionTests.swift new file mode 100644 index 000000000..ebb0ae43b --- /dev/null +++ b/BitkitTests/WeatherConditionTests.swift @@ -0,0 +1,163 @@ +@testable import Bitkit +import XCTest + +// MARK: - FeeCondition.evaluate + +final class FeeConditionEvaluateTests: XCTestCase { + /// Native-segwit transaction size used for the USD threshold check. + private static let vBytesSize = 140 + /// $1 USD threshold below which the widget is always "Favorable". + private static let usdGoodThreshold = 1.0 + /// Plausible mainnet BTC/USD rate. The tests only care that values are above/below the + /// $1 threshold; the exact number doesn't matter. + private static let usdPerBtc = 100_000.0 + + private static let percentile = FeePercentile(lowThreshold: 5, highThreshold: 50) + + func testEvaluate_UsdThresholdReturnsGood() { + // 1 sat/vB × 140 vB at $100k/BTC ≈ $0.14 → ≤ $1 → .good regardless of percentile + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 100, + totalSats: 1 * Self.vBytesSize, + usdPerBtc: Self.usdPerBtc, + percentile: Self.percentile + ) + XCTAssertEqual(condition, .good) + } + + func testEvaluate_UsdAboveOneFallsThroughToPercentile_Good() { + // 4 sat/vB × 140 vB at $100k/BTC ≈ $0.56 — under threshold so USD branch returns .good. + // Pick numbers that are clearly above $1: 100 sat/vB total → ~$14. + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 4, // below low threshold (5) + totalSats: 100 * Self.vBytesSize, + usdPerBtc: Self.usdPerBtc, + percentile: Self.percentile + ) + XCTAssertEqual(condition, .good) + } + + func testEvaluate_NoUsdRateFallsThroughToPercentile_Average() { + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 20, // between low (5) and high (50) + totalSats: 20 * Self.vBytesSize, + usdPerBtc: nil, + percentile: Self.percentile + ) + XCTAssertEqual(condition, .average) + } + + func testEvaluate_MidRateAboveHighThresholdReturnsPoor() { + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 60, // above high threshold (50) + totalSats: 60 * Self.vBytesSize, + usdPerBtc: Self.usdPerBtc, + percentile: Self.percentile + ) + XCTAssertEqual(condition, .poor) + } + + func testEvaluate_MidRateBelowLowThresholdReturnsGood() { + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 3, // below low threshold (5) + totalSats: 50 * Self.vBytesSize, // ~$7 — above $1, so falls to percentile + usdPerBtc: Self.usdPerBtc, + percentile: Self.percentile + ) + XCTAssertEqual(condition, .good) + } + + func testEvaluate_MidRateBetweenThresholdsReturnsAverage() { + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 20, // between low (5) and high (50) + totalSats: 20 * Self.vBytesSize, + usdPerBtc: Self.usdPerBtc, + percentile: Self.percentile + ) + XCTAssertEqual(condition, .average) + } + + func testEvaluate_MissingPercentileReturnsAverage() { + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 100, + totalSats: 100 * Self.vBytesSize, + usdPerBtc: Self.usdPerBtc, + percentile: nil + ) + XCTAssertEqual(condition, .average) + } + + func testEvaluate_ZeroUsdPerBtcIgnoresUsdCheck() { + // Zero rate means the USD branch must be skipped, falling through to the percentile. + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 20, // between low (5) and high (50) + totalSats: 20 * Self.vBytesSize, + usdPerBtc: 0, + percentile: Self.percentile + ) + XCTAssertEqual(condition, .average) + } + + func testEvaluate_BoundaryAtLowThresholdReturnsGood() { + // Equal to low threshold → Good (`<=`). + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 5, + totalSats: 5 * Self.vBytesSize, + usdPerBtc: nil, // bypass USD branch + percentile: Self.percentile + ) + XCTAssertEqual(condition, .good) + } + + func testEvaluate_BoundaryAtHighThresholdReturnsPoor() { + // Equal to high threshold → Poor (`>=`). + let condition = FeeCondition.evaluate( + midSatsPerVbyte: 50, + totalSats: 50 * Self.vBytesSize, + usdPerBtc: nil, // bypass USD branch + percentile: Self.percentile + ) + XCTAssertEqual(condition, .poor) + } +} + +// MARK: - FeePercentile.init(history:) + +final class FeePercentileInitTests: XCTestCase { + func testInit_EmptyHistoryReturnsNil() { + XCTAssertNil(FeePercentile(history: [])) + } + + func testInit_ComputesPercentiles() { + // Sorted values [0, 1, 2, ..., 99] → 33rd-percentile index = floor(100 * 0.33) = 33, + // 66th-percentile index = 66. Production code uses `Int(Double(n) * percentile)` which + // truncates toward zero — keep this assertion aligned with that exact indexing. + let history = (0 ..< 100).map { makeRates(avgFee50: Double($0)) } + let percentile = FeePercentile(history: history) + XCTAssertEqual(percentile?.lowThreshold, 33) + XCTAssertEqual(percentile?.highThreshold, 66) + } + + func testInit_UnsortedInputProducesSortedThresholds() { + // Same values as the previous test, just shuffled — thresholds must be computed from + // sorted order so they should match exactly. + let values: [Double] = (0 ..< 100).map(Double.init).shuffled() + let history = values.map { makeRates(avgFee50: $0) } + let percentile = FeePercentile(history: history) + XCTAssertEqual(percentile?.lowThreshold, 33) + XCTAssertEqual(percentile?.highThreshold, 66) + } + + func testInit_SingleSampleProducesSameLowAndHighThreshold() { + let history = [makeRates(avgFee50: 7.5)] + let percentile = FeePercentile(history: history) + XCTAssertEqual(percentile?.lowThreshold, 7.5) + XCTAssertEqual(percentile?.highThreshold, 7.5) + } + + // MARK: - Helpers + + private func makeRates(avgFee50: Double) -> BlockFeeRates { + BlockFeeRates(avgFee_50: avgFee50) + } +} diff --git a/BitkitTests/WeatherWidgetOptionsDecodingTests.swift b/BitkitTests/WeatherWidgetOptionsDecodingTests.swift new file mode 100644 index 000000000..923b8eb5a --- /dev/null +++ b/BitkitTests/WeatherWidgetOptionsDecodingTests.swift @@ -0,0 +1,84 @@ +@testable import Bitkit +import XCTest + +/// Locks in the v60 → v61 upgrade contract for `WeatherWidgetOptions`. Users with stored options +/// from the four-toggle layout must keep the metric they were using; new blobs round-trip +/// cleanly; missing/garbage blobs fall back to the default `.fiatFee`. +final class WeatherWidgetOptionsDecodingTests: XCTestCase { + private func decode(_ json: String) throws -> WeatherWidgetOptions { + let data = Data(json.utf8) + return try JSONDecoder().decode(WeatherWidgetOptions.self, from: data) + } + + // MARK: - v61 (new) shape + + func testDecode_NewSelectedMetricKeyWins() throws { + let options = try decode(#"{"selectedMetric":"nextBlockFee"}"#) + XCTAssertEqual(options.selectedMetric, .nextBlockFee) + } + + func testDecode_NewSelectedMetricFiatFee() throws { + let options = try decode(#"{"selectedMetric":"fiatFee"}"#) + XCTAssertEqual(options.selectedMetric, .fiatFee) + } + + func testDecode_NewSelectedMetricSatsFee() throws { + let options = try decode(#"{"selectedMetric":"satsFee"}"#) + XCTAssertEqual(options.selectedMetric, .satsFee) + } + + // MARK: - v60 (legacy) shape + + func testDecode_LegacyShowNextBlockFeeOnly_MapsToNextBlock() throws { + let options = try decode( + #"{"showStatus":true,"showText":true,"showMedian":false,"showNextBlockFee":true}"# + ) + XCTAssertEqual(options.selectedMetric, .nextBlockFee) + } + + func testDecode_LegacyShowMedianOnly_MapsToFiat() throws { + let options = try decode( + #"{"showStatus":true,"showText":true,"showMedian":true,"showNextBlockFee":false}"# + ) + XCTAssertEqual(options.selectedMetric, .fiatFee) + } + + func testDecode_LegacyBothMedianAndNextBlock_PrefersFiat() throws { + // Old default had both visible; pick the fee metric (fiat) we now show as the v61 default. + let options = try decode( + #"{"showStatus":true,"showText":true,"showMedian":true,"showNextBlockFee":true}"# + ) + XCTAssertEqual(options.selectedMetric, .fiatFee) + } + + func testDecode_LegacyAllFalse_FallsBackToFiat() throws { + let options = try decode( + #"{"showStatus":false,"showText":false,"showMedian":false,"showNextBlockFee":false}"# + ) + XCTAssertEqual(options.selectedMetric, .fiatFee) + } + + // MARK: - Missing / unknown + + func testDecode_EmptyObject_FallsBackToFiat() throws { + let options = try decode("{}") + XCTAssertEqual(options.selectedMetric, .fiatFee) + } + + func testDecode_UnknownMetricValue_Throws() { + XCTAssertThrowsError(try decode(#"{"selectedMetric":"bogus"}"#)) + } + + // MARK: - Round-trip + + func testEncodeRoundtrip_OnlyWritesSelectedMetric() throws { + let original = WeatherWidgetOptions(selectedMetric: .satsFee) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(WeatherWidgetOptions.self, from: data) + XCTAssertEqual(decoded, original) + + // The serialized blob must not carry the obsolete legacy keys forward. + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(jsonObject?.keys.sorted(), ["selectedMetric"]) + } +} diff --git a/BitkitTests/WidgetsViewModelDedupTests.swift b/BitkitTests/WidgetsViewModelDedupTests.swift new file mode 100644 index 000000000..a27653dc1 --- /dev/null +++ b/BitkitTests/WidgetsViewModelDedupTests.swift @@ -0,0 +1,57 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class WidgetsViewModelDedupTests: XCTestCase { + func testDedupedByType_RemovesDuplicateWeatherEntries() { + let input: [SavedWidget] = [ + SavedWidget(type: .suggestions), + SavedWidget(type: .weather), + SavedWidget(type: .price), + SavedWidget(type: .weather), + ] + let result = WidgetsViewModel.dedupedByType(input) + XCTAssertEqual(result.map(\.type), [.suggestions, .weather, .price]) + } + + func testDedupedByType_PrefersEntryWithOptionsData() { + let optionsData = Data([0xAB, 0xCD]) + let input: [SavedWidget] = [ + SavedWidget(type: .weather, optionsData: nil), + SavedWidget(type: .weather, optionsData: optionsData), + ] + let result = WidgetsViewModel.dedupedByType(input) + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.type, .weather) + XCTAssertEqual(result.first?.optionsData, optionsData) + } + + func testDedupedByType_NoChangeWhenAlreadyUnique() { + let input: [SavedWidget] = [ + SavedWidget(type: .suggestions), + SavedWidget(type: .price), + SavedWidget(type: .blocks), + SavedWidget(type: .weather, optionsData: Data([0x01])), + ] + let result = WidgetsViewModel.dedupedByType(input) + XCTAssertEqual(result.map(\.type), input.map(\.type)) + XCTAssertEqual(result.last?.optionsData, Data([0x01])) + } + + func testDedupedByType_PrefersFirstNonNilOptionsAcrossMultipleDups() { + let first = Data([0x01]) + let second = Data([0x02]) + let input: [SavedWidget] = [ + SavedWidget(type: .weather, optionsData: nil), + SavedWidget(type: .weather, optionsData: first), + SavedWidget(type: .weather, optionsData: second), + ] + let result = WidgetsViewModel.dedupedByType(input) + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.optionsData, first) + } + + func testDedupedByType_EmptyInputReturnsEmpty() { + XCTAssertTrue(WidgetsViewModel.dedupedByType([]).isEmpty) + } +} diff --git a/BitkitWidget/BitkitWidget.swift b/BitkitWidget/BitkitWidget.swift index dfd5eb8f9..0c7223295 100644 --- a/BitkitWidget/BitkitWidget.swift +++ b/BitkitWidget/BitkitWidget.swift @@ -8,5 +8,6 @@ struct BitkitWidgetBundle: WidgetBundle { BitkitNewsWidget() BitkitBlocksWidget() BitkitFactsWidget() + BitkitWeatherWidget() } } diff --git a/BitkitWidget/WeatherHomeScreenWidget.swift b/BitkitWidget/WeatherHomeScreenWidget.swift new file mode 100644 index 000000000..6aec753a4 --- /dev/null +++ b/BitkitWidget/WeatherHomeScreenWidget.swift @@ -0,0 +1,126 @@ +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct WeatherWidgetEntry: TimelineEntry { + let date: Date + let data: CachedWeather? + let options: WeatherWidgetOptions +} + +// MARK: - Timeline Provider + +struct WeatherWidgetProvider: TimelineProvider { + private static let refreshInterval: TimeInterval = 10 * 60 + + /// Stable mock for widget gallery / placeholder snapshots. + private static let mockData = CachedWeather( + condition: .good, + currentFeeFiat: "$ 0.52", + currentFeeSats: 520, + nextBlockFee: 6 + ) + + private static let mockEntry = WeatherWidgetEntry( + date: Date(), + data: mockData, + options: WeatherWidgetOptions() + ) + + func placeholder(in _: Context) -> WeatherWidgetEntry { + Self.mockEntry + } + + func getSnapshot(in context: Context, completion: @escaping (WeatherWidgetEntry) -> Void) { + let options = WeatherHomeScreenWidgetOptionsStore.load() + + if context.isPreview { + let data = WeatherWidgetService.cachedLatest() ?? Self.mockData + completion(WeatherWidgetEntry(date: Date(), data: data, options: options)) + return + } + + if let cached = WeatherWidgetService.cachedLatest() { + completion(WeatherWidgetEntry(date: Date(), data: cached, options: options)) + return + } + + Task { + let fresh = try? await WeatherWidgetService.fetchFreshLatest() + completion(WeatherWidgetEntry(date: Date(), data: fresh, options: options)) + } + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let options = WeatherHomeScreenWidgetOptionsStore.load() + let nextRefresh = Date().addingTimeInterval(Self.refreshInterval) + + Task { + let data = await WeatherWidgetService.latestWeather() + let entry = WeatherWidgetEntry(date: Date(), data: data, options: options) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } + } +} + +// MARK: - View + +struct WeatherHomeScreenWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + @Environment(\.widgetRenderingMode) var widgetRenderingMode + + var entry: WeatherWidgetProvider.Entry + + var body: some View { + content + .containerBackground(for: .widget) { backgroundView } + } + + @ViewBuilder + private var content: some View { + if let data = entry.data { + let metric = entry.options.selectedMetric + switch widgetFamily { + case .systemSmall: + WeatherWidgetCompactContent( + data: data, + metric: metric, + conditionTitle: t(data.condition.shortTitleKey), + metricLabel: t(metric.labelKey) + ) + default: + WeatherWidgetWideContent( + data: data, + metric: metric, + conditionTitle: t(data.condition.titleKey), + conditionDescription: t(data.condition.descriptionKey), + metricLabel: t(metric.labelKey) + ) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + + private var backgroundView: some View { + widgetRenderingMode == .fullColor ? Color.gray6 : Color.clear + } +} + +// MARK: - Widget Configuration + +struct BitkitWeatherWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration( + kind: WeatherHomeScreenWidgetOptionsStore.weatherHomeScreenWidgetKind, + provider: WeatherWidgetProvider() + ) { entry in + WeatherHomeScreenWidgetEntryView(entry: entry) + } + .configurationDisplayName(t("widgets__weather__name")) + .description(t("widgets__weather__description")) + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/BitkitWidget/WeatherWidgetService.swift b/BitkitWidget/WeatherWidgetService.swift new file mode 100644 index 000000000..7e2fa44ce --- /dev/null +++ b/BitkitWidget/WeatherWidgetService.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Slim Bitcoin fee weather fetcher used inside the WidgetKit extension. Network/decoding is +/// delegated to `MempoolWeatherAPI` so the URL strings and wire shapes stay in one place. +enum WeatherWidgetService { + enum FetchError: Error { + case missingData + } + + /// Average native segwit transaction size used to convert sats/vByte → total sats. + private static let vbytesSize = 140 + + static func cachedLatest() -> CachedWeather? { + WeatherWidgetCache.loadLatest() + } + + /// Returns the cached weather only while it's within the freshness TTL set by + /// `WeatherWidgetCache.cacheFreshnessTTL`. The widget timeline uses this so it can fall + /// back to its own fetch when the main app hasn't refreshed in a while. + static func cachedLatestIfFresh() -> CachedWeather? { + WeatherWidgetCache.loadLatestIfFresh() + } + + static func latestWeather() async -> CachedWeather? { + if let fresh = cachedLatestIfFresh() { return fresh } + if let fresh = try? await fetchFreshLatest() { return fresh } + return cachedLatest() + } + + static func fetchFreshLatest() async throws -> CachedWeather { + async let feesPromise = MempoolWeatherAPI.fetchRecommendedFees() + async let pricesPromise = MempoolWeatherAPI.fetchPrices() + async let percentilePromise = resolvePercentile() + + let fees = try await feesPromise + let prices = await (try? pricesPromise) ?? [:] + let percentile = try? await percentilePromise + + // USD is always used for the $1 "favorable" threshold, regardless of display currency. + let usdRate = prices["USD"] + let displayCurrency = WeatherCurrencyAppGroupStore.load() + let displayRate = prices[displayCurrency] ?? usdRate + let resolvedCurrency = prices[displayCurrency] != nil ? displayCurrency : WeatherCurrencyAppGroupStore.fallbackCode + + let midSatsPerVbyte = Double(fees.halfHourFee) + let medianFeeSats = fees.halfHourFee * Self.vbytesSize + + let condition = FeeCondition.evaluate( + midSatsPerVbyte: midSatsPerVbyte, + totalSats: medianFeeSats, + usdPerBtc: usdRate, + percentile: percentile + ) + + let fiatString = formatFiat( + sats: medianFeeSats, + currencyPerBtc: displayRate, + currencyCode: resolvedCurrency + ) + + let entry = CachedWeather( + condition: condition, + currentFeeFiat: fiatString, + currentFeeSats: medianFeeSats, + nextBlockFee: fees.fastestFee + ) + + // Persist to the shared App Group cache. The main app will overwrite this on the next + // foreground refresh; until then the next timeline tick within the TTL reuses our write. + WeatherWidgetCache.saveLatest(entry) + return entry + } + + // MARK: - Percentile resolution + + /// Returns a cached `FeePercentile` if one is fresh (within `WeatherWidgetCache.percentileTTL`), + /// otherwise fetches the 3-month history and caches the freshly computed percentile. + private static func resolvePercentile() async throws -> FeePercentile { + if let cached = WeatherWidgetCache.loadPercentile() { + return cached + } + let history = try await MempoolWeatherAPI.fetchHistoricalFees() + guard let percentile = FeePercentile(history: history) else { + throw FetchError.missingData + } + WeatherWidgetCache.savePercentile(percentile) + return percentile + } + + // MARK: - Formatting + + /// Formats a satoshi amount in the user's selected display currency. Falls back to a "—" + /// placeholder string formatted in the resolved currency when the rate is missing. + private static func formatFiat(sats: Int, currencyPerBtc: Double?, currencyCode: String) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currencyCode + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 2 + + guard let currencyPerBtc, currencyPerBtc > 0 else { + let symbol = formatter.currencySymbol ?? currencyCode + return "\(symbol) —" + } + + let amount = Double(sats) / 100_000_000.0 * currencyPerBtc + return formatter.string(from: NSNumber(value: amount)) ?? String(format: "%.2f \(currencyCode)", amount) + } +} diff --git a/changelog.d/next/weather-widget-v61.added.md b/changelog.d/next/weather-widget-v61.added.md new file mode 100644 index 000000000..3ce5afd00 --- /dev/null +++ b/changelog.d/next/weather-widget-v61.added.md @@ -0,0 +1 @@ +Redesigned the Weather widget with the v61 look and added a Bitcoin Weather home-screen widget for iOS.