From 2ee31b5ad70558ca7f90585e06f46828a5d49c3e Mon Sep 17 00:00:00 2001 From: sl Date: Tue, 19 May 2026 16:45:58 +0200 Subject: [PATCH 1/2] ios: use simple price for widget current price --- .../BitBoxAppWidget/WidgetDataService.swift | 40 +++++++++++++++++-- frontends/ios/WIDGET.md | 22 +++++++--- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetDataService.swift b/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetDataService.swift index 17d96ae16f..57e1b44f0f 100644 --- a/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetDataService.swift +++ b/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetDataService.swift @@ -64,6 +64,8 @@ struct WidgetDataService { return nil } + async let currentPriceFromSimplePrice = fetchCurrentPrice(coinCode: coinCode, currency: currency) + do { let (data, response) = try await session.data(from: url) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 @@ -85,18 +87,21 @@ struct WidgetDataService { return nil } - guard let currentPrice = prices.last, + guard let rangeCurrentPrice = prices.last, let firstPrice = prices.first, - currentPrice.isFinite, + rangeCurrentPrice.isFinite, firstPrice.isFinite, firstPrice != 0 else { return nil } + let simplePrice = await currentPriceFromSimplePrice + let currentPrice = simplePrice ?? rangeCurrentPrice + let chartPrices = simplePrice.map { prices + [$0] } ?? prices let change = (currentPrice - firstPrice) / firstPrice * 100 let result = PriceData( price: currentPrice, change24h: change, - chartPrices: prices, + chartPrices: chartPrices, coinCode: WidgetShared.normalizeCoinCode(coinCode), currency: currency.uppercased() ) @@ -107,6 +112,30 @@ struct WidgetDataService { } } + private func fetchCurrentPrice(coinCode: String, currency: String) async -> Double? { + guard let url = simplePriceURL(for: coinCode, currency: currency) else { + return nil + } + + do { + let (data, response) = try await session.data(from: url) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + guard statusCode == 200, + let decoded = try? JSONDecoder().decode([String: [String: Double]].self, from: data) else { + return nil + } + let geckoID = WidgetCoinMetadata.geckoID(for: coinCode) + let geckoCurrency = currency.lowercased() + guard let currentPrice = decoded[geckoID]?[geckoCurrency], + currentPrice.isFinite else { + return nil + } + return currentPrice + } catch { + return nil + } + } + private func chartURL(for coinCode: String, currency: String) -> URL? { let now = Int(Date().timeIntervalSince1970) let oneDayAgo = now - Self.chartRangeSeconds @@ -114,6 +143,11 @@ struct WidgetDataService { return URL(string: "https://exchangerates.shiftcrypto.io/api/v3/coins/\(geckoID)/market_chart/range?vs_currency=\(currency.lowercased())&from=\(oneDayAgo)&to=\(now)") } + private func simplePriceURL(for coinCode: String, currency: String) -> URL? { + let geckoID = WidgetCoinMetadata.geckoID(for: coinCode) + return URL(string: "https://exchangerates.shiftcrypto.io/api/v3/simple/price?ids=\(geckoID)&vs_currencies=\(currency.lowercased())") + } + private static func makeSession() -> URLSession { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 10 diff --git a/frontends/ios/WIDGET.md b/frontends/ios/WIDGET.md index 36ad5e0f81..da78ada6ca 100644 --- a/frontends/ios/WIDGET.md +++ b/frontends/ios/WIDGET.md @@ -46,7 +46,8 @@ The widget shows **one coin at a time**. The user switches coins with left/right ## 3. How prices are fetched -The widget calls the **Shift Crypto CoinGecko Mirror API**: +The widget calls the **Shift Crypto CoinGecko Mirror API**. It uses the chart endpoint for historical +data: ``` https://exchangerates.shiftcrypto.io/api/v3/coins/{geckoID}/market_chart/range @@ -55,15 +56,24 @@ https://exchangerates.shiftcrypto.io/api/v3/coins/{geckoID}/market_chart/range &to={now} ``` +It also calls the simple price endpoint for a fresher current price: + +``` +https://exchangerates.shiftcrypto.io/api/v3/simple/price + ?ids={geckoID} + &vs_currencies={currency} +``` + Where `geckoID` maps `btc` -> `bitcoin`, `ltc` -> `litecoin`, `eth` -> `ethereum`. -This returns 24 hours of price data points. From that response: +The chart response returns 24 hours of price data points. The simple price response is appended to +that series when available. From those responses: -- **Current price** = last data point. -- **24-hour change** = percentage difference between first and last data points. -- **Chart data** = all returned price points (used for the sparkline). +- **Current price** = simple price when available, otherwise the chart response's last data point. +- **24-hour change** = percentage difference between the chart response's first data point and the current price. +- **Chart data** = all returned chart price points plus the simple price when available (used for the sparkline). -The request has a **10-second timeout**. +Each request has a **10-second timeout**. --- From 94165499102da97168b22352597376eb13ebae5d Mon Sep 17 00:00:00 2001 From: sl Date: Wed, 20 May 2026 09:39:25 +0200 Subject: [PATCH 2/2] ios: refresh widget on app lifecycle --- frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift | 6 +++--- .../ios/BitBoxApp/BitBoxApp/WidgetAppGroupSync.swift | 12 ++++++++---- frontends/ios/BitBoxApp/BitBoxApp/WidgetShared.swift | 1 + .../BitBoxAppWidget/WidgetAppGroupStore.swift | 8 ++++++++ .../BitBoxAppWidget/WidgetTimelineProvider.swift | 6 +++++- frontends/ios/WIDGET.md | 6 +++++- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift b/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift index 7fa1f9f541..84fa3f790a 100644 --- a/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift +++ b/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift @@ -200,14 +200,14 @@ struct BitBoxAppApp: App { setupGoAPI(goAPI: goAPI) MobileserverSetOnline(NetworkMonitor.shared.isOnline()) Task.detached(priority: .utility) { - widgetSync.sync() + widgetSync.sync(forceReload: true) } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in MobileserverManualReconnect() MobileserverTriggerAuth() Task.detached(priority: .utility) { - widgetSync.sync() + widgetSync.sync(forceReload: true) } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in @@ -219,7 +219,7 @@ struct BitBoxAppApp: App { } } Task.detached(priority: .userInitiated) { - widgetSync.sync() + widgetSync.sync(forceReload: true) await MainActor.run { if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask) diff --git a/frontends/ios/BitBoxApp/BitBoxApp/WidgetAppGroupSync.swift b/frontends/ios/BitBoxApp/BitBoxApp/WidgetAppGroupSync.swift index 37d54f8a46..5cd8ae0458 100644 --- a/frontends/ios/BitBoxApp/BitBoxApp/WidgetAppGroupSync.swift +++ b/frontends/ios/BitBoxApp/BitBoxApp/WidgetAppGroupSync.swift @@ -22,16 +22,16 @@ struct WidgetAppGroupSync { private static let serialQueue = DispatchQueue(label: "swiss.bitbox.WidgetAppGroupSync") - func sync() { + func sync(forceReload: Bool = false) { #if !TARGET_TESTNET Self.serialQueue.sync { - performSync() + performSync(forceReload: forceReload) } #endif } #if !TARGET_TESTNET - private func performSync() { + private func performSync(forceReload: Bool) { let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! guard let defaults = UserDefaults(suiteName: WidgetShared.appGroupID) else { return @@ -78,7 +78,11 @@ struct WidgetAppGroupSync { } } - if changed { + if forceReload { + defaults.set(true, forKey: WidgetShared.Keys.forceFreshPriceReload) + } + + if changed || forceReload { WidgetCenter.shared.reloadAllTimelines() } } diff --git a/frontends/ios/BitBoxApp/BitBoxApp/WidgetShared.swift b/frontends/ios/BitBoxApp/BitBoxApp/WidgetShared.swift index e26f9c07e0..5d2b356ab2 100644 --- a/frontends/ios/BitBoxApp/BitBoxApp/WidgetShared.swift +++ b/frontends/ios/BitBoxApp/BitBoxApp/WidgetShared.swift @@ -14,6 +14,7 @@ enum WidgetShared { static let rawMainFiat = "rawMainFiat" static let sharedCoins = "activeCoins" static let selectedCoinIndex = "selectedCoinIndex" + static let forceFreshPriceReload = "forceFreshPriceReload" } enum Cache { diff --git a/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetAppGroupStore.swift b/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetAppGroupStore.swift index bd891afebc..1c7d015a5a 100644 --- a/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetAppGroupStore.swift +++ b/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetAppGroupStore.swift @@ -23,6 +23,14 @@ enum WidgetAppGroupStore { defaults?.set(index, forKey: WidgetShared.Keys.selectedCoinIndex) } + static func shouldForceFreshPriceReload() -> Bool { + defaults?.bool(forKey: WidgetShared.Keys.forceFreshPriceReload) == true + } + + static func clearForceFreshPriceReload() { + defaults?.set(false, forKey: WidgetShared.Keys.forceFreshPriceReload) + } + static func selectedCoinCode() -> String { let coins = activeCoins() guard !coins.isEmpty else { diff --git a/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetTimelineProvider.swift b/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetTimelineProvider.swift index a9b987cec9..0f65d4e666 100644 --- a/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetTimelineProvider.swift +++ b/frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetTimelineProvider.swift @@ -45,8 +45,9 @@ struct Provider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let coinCode = WidgetAppGroupStore.selectedCoinCode() let currency = WidgetAppGroupStore.userCurrency() + let forceFreshPriceReload = WidgetAppGroupStore.shouldForceFreshPriceReload() - let cached = dataService.cachedFallback(for: coinCode, currency: currency) + let cached = forceFreshPriceReload ? nil : dataService.cachedFallback(for: coinCode, currency: currency) let exactCacheHit = cached.flatMap { $0.currency.uppercased() == currency.uppercased() ? $0 : nil } if let exactCacheHit { @@ -56,6 +57,9 @@ struct Provider: TimelineProvider { } else { Task { let fetched = await dataService.fetchChartData(coinCode: coinCode, currency: currency) + if fetched != nil { + WidgetAppGroupStore.clearForceFreshPriceReload() + } let data = fetched ?? dataService.cachedFallback(for: coinCode, currency: currency) let entry = resolvedEntry(for: coinCode, currency: currency, data: data) let nextUpdate = Date().addingTimeInterval(fetched == nil ? Self.retryInterval : Self.refreshInterval) diff --git a/frontends/ios/WIDGET.md b/frontends/ios/WIDGET.md index da78ada6ca..e878b08499 100644 --- a/frontends/ios/WIDGET.md +++ b/frontends/ios/WIDGET.md @@ -27,7 +27,8 @@ When the app launches (or enters foreground / background), `WidgetAppGroupSync` 2. Reads `accounts.json` -> collects all active (non-inactive, non-hidden) coin codes. 3. Normalizes coin codes (`tbtc` -> `btc`, `sepeth` -> `eth`, `tltc` -> `ltc`). 4. Writes the currency and coin list to shared `UserDefaults`. -5. If anything changed, tells iOS to reload the widget timeline. +5. Tells iOS to reload the widget timeline on app launch / foreground / background. +6. Marks that lifecycle reload as a fresh-price reload so the widget skips its cache once. **Fallback:** If the user's currency isn't supported by the API (e.g. `BTC` or `sat`), it falls back to `USD`. @@ -98,6 +99,8 @@ When the widget refreshes, it follows this order: Switching to a coin with a warm cache feels instant. The first switch to a coin whose cache is cold (or expired) performs one inline fetch before rendering. +App lifecycle reloads (launch / foreground / background) bypass the cache and attempt a fresh network fetch. If that fetch fails, the widget keeps bypassing the cache on retry until a fresh fetch succeeds. + ### Fallback chain If the network fetch fails: @@ -115,6 +118,7 @@ If the network fetch fails: - With the 10-minute cache TTL, each iOS-granted timeline reload should either use recent cached data or fetch fresh data. - If a network fetch fails, the widget asks iOS to retry in **1 minute**. - If the user changes their currency or accounts in the app, a refresh is triggered immediately. +- App launch / foreground / background also triggers a widget timeline reload that bypasses the cache once. ---