diff --git a/iosApp/StockTickerWidget/StockTickerWidget.swift b/iosApp/StockTickerWidget/StockTickerWidget.swift index c28ed104..79e34c03 100644 --- a/iosApp/StockTickerWidget/StockTickerWidget.swift +++ b/iosApp/StockTickerWidget/StockTickerWidget.swift @@ -46,11 +46,25 @@ struct StockTickerProvider: AppIntentTimelineProvider { func timeline(for configuration: StockTickerConfigurationIntent, in context: Context) async -> Timeline { let entry = loadEntry(for: configuration) - // The app reloads timelines on every refresh; also poll periodically as a fallback. - let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() + // Reload the timeline at the user's selected update interval so the widget refreshes at the + // cadence chosen in the app (WidgetKit still enforces its own system minimum). Falls back to + // 30 minutes for snapshots written by older app versions that didn't record an interval. + let snapshot = WidgetSnapshotStore.companion.create().read() + let intervalMinutes = Self.refreshIntervalMinutes(from: snapshot) + let next = Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: Date()) ?? Date() return Timeline(entries: [entry], policy: .after(next)) } + /// The widget's next-reload interval, in minutes, derived from the user's selected update interval + /// in the shared snapshot. Clamped to a small floor so WidgetKit isn't asked for an unreasonably + /// tight cadence, defaulting to 30 minutes when no interval was recorded. + private static func refreshIntervalMinutes(from snapshot: WidgetSnapshot?) -> Int { + let millis = snapshot.map { Int64($0.updateIntervalMillis) } ?? 0 + guard millis > 0 else { return 30 } + let minutes = Int(millis / 60_000) + return max(5, minutes) + } + private func loadEntry(for configuration: StockTickerConfigurationIntent) -> StockTickerEntry { let snapshot = WidgetSnapshotStore.companion.create().read() var rows = (snapshot?.quotes ?? []).map { quote in @@ -68,9 +82,11 @@ struct StockTickerProvider: AppIntentTimelineProvider { if let selected = configuration.selectedSymbols { rows = rows.filter { selected.contains($0.symbol) } } - // Per-widget sort: optionally show the largest movers first. + // Per-widget sort: optionally show the largest gainers first, matching the app's auto-sort + // (change % descending). The snapshot is written in the raw watchlist order, so this toggle + // is authoritative: enabling it sorts, disabling it keeps the watchlist order. if configuration.sortByChange { - rows.sort { abs($0.changeInPercent) > abs($1.changeInPercent) } + rows.sort { $0.changeInPercent > $1.changeInPercent } } let date = snapshot.map { Date(timeIntervalSince1970: Double($0.lastUpdatedMillis) / 1000.0) } ?? Date() return StockTickerEntry(date: date, quotes: rows, isPlaceholder: false, configuration: configuration) @@ -78,11 +94,11 @@ struct StockTickerProvider: AppIntentTimelineProvider { private static let sampleRows: [WidgetQuoteRow] = [ WidgetQuoteRow(symbol: "AAPL", name: "Apple Inc.", price: "$192.32", - changePercent: "+1.24%", changeAmount: "+2.35", changeInPercent: 1.24, positive: true), + changePercent: "1.24%", changeAmount: "2.35", changeInPercent: 1.24, positive: true), WidgetQuoteRow(symbol: "MSFT", name: "Microsoft", price: "$421.10", changePercent: "-0.42%", changeAmount: "-1.78", changeInPercent: -0.42, positive: false), WidgetQuoteRow(symbol: "GOOG", name: "Alphabet", price: "$175.98", - changePercent: "+0.88%", changeAmount: "+1.54", changeInPercent: 0.88, positive: true), + changePercent: "0.88%", changeAmount: "1.54", changeInPercent: 0.88, positive: true), ] } diff --git a/iosApp/iosApp/ForegroundRefreshCoordinator.swift b/iosApp/iosApp/ForegroundRefreshCoordinator.swift new file mode 100644 index 00000000..6cc92808 --- /dev/null +++ b/iosApp/iosApp/ForegroundRefreshCoordinator.swift @@ -0,0 +1,63 @@ +import Foundation +import Shared +import WidgetKit + +/// Drives an "aggressive" foreground refresh so the app and its home-screen widgets update at the +/// user's selected update interval while the app is active. +/// +/// iOS heavily throttles true background execution (`BGTaskScheduler` only runs opportunistically, on +/// the system's schedule), so it can't guarantee refreshes at an exact cadence. To make the app feel +/// like it honours the chosen interval — the iOS analogue of Android's foreground refresh — this +/// polls the shared `StocksProvider` whenever a refresh becomes due while the app is in the +/// foreground, reloading the WidgetKit timelines each time. When the app is backgrounded the app +/// hands off to a `BGAppRefreshTask` (see `StockTickerApp`). +final class ForegroundRefreshCoordinator { + + private var loop: Task? + + /// Starts (or restarts) the foreground refresh loop. Idempotent: an existing loop is cancelled + /// first so callers can safely invoke this on every transition to the active scene phase. + func start() { + stop() + loop = Task { + let provider = KoinHelper.shared.stocksProvider() + while !Task.isCancelled { + let now = Self.nowMillis() + let nextFetch = KoinHelper.shared.nextFetchMillis() + // Wait until the next scheduled fetch is due, but wake up at least once per interval + // so a far-future schedule (e.g. outside the update window) is re-evaluated. + let intervalMs = max(KoinHelper.shared.updateIntervalMillis(), Self.minIntervalMs) + let waitMs = min(max(0, nextFetch - now), intervalMs) + if waitMs > 0 { + do { + try await Task.sleep(nanoseconds: UInt64(waitMs) * 1_000_000) + } catch { + break + } + } + if Task.isCancelled { break } + // Only fetch when a refresh is actually due (guards against the interval wake-up). + if Self.nowMillis() >= KoinHelper.shared.nextFetchMillis() { + _ = try? await provider.fetch(allowScheduling: true) + WidgetCenterReloader.reloadAll() + } + } + } + } + + /// Cancels the foreground refresh loop. Called when the app leaves the foreground. + func stop() { + loop?.cancel() + loop = nil + } + + deinit { + stop() + } + + private static let minIntervalMs: Int64 = 60_000 + + private static func nowMillis() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) + } +} diff --git a/iosApp/iosApp/StockTickerApp.swift b/iosApp/iosApp/StockTickerApp.swift index f7d6173d..02e1fdeb 100644 --- a/iosApp/iosApp/StockTickerApp.swift +++ b/iosApp/iosApp/StockTickerApp.swift @@ -31,6 +31,11 @@ struct StockTickerApp: App { /// the app's lifetime. Cancelling/dropping it would stop the widget from receiving updates. private let widgetSnapshotSync = WidgetSnapshotSync() + /// Refreshes quotes/widgets at the user's selected interval while the app is in the foreground. + private let foregroundRefresh = ForegroundRefreshCoordinator() + + @Environment(\.scenePhase) private var scenePhase + init() { configureFirebase() // Start Koin with the shared graph and the iOS platform implementations. @@ -62,9 +67,34 @@ struct StockTickerApp: App { var body: some Scene { WindowGroup { ContentView() + .task { + // Kick off the aggressive foreground refresh as soon as the UI appears (cold + // launch does not emit a scenePhase change for the initial `.active` value). + foregroundRefresh.start() + } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + // Resume aggressive foreground refreshing at the user's selected interval. + foregroundRefresh.start() + case .background: + // Stop the foreground loop and hand off to a background refresh so quotes and + // widgets keep updating (as close to the selected interval as iOS allows). + foregroundRefresh.stop() + scheduleBackgroundRefresh() + default: + break + } + } } } + /// Submits a background refresh scaled to the user's selected update interval, so the widget/app + /// keeps updating after the app is backgrounded (subject to the OS's background scheduling). + private func scheduleBackgroundRefresh() { + backgroundScheduler.enqueuePeriodicRefresh(intervalMs: KoinHelper.shared.updateIntervalMillis()) + } + /// Configures the Firebase iOS SDK when it is linked and a `GoogleService-Info.plist` is present /// (the prod build). For the FOSS build — no Firebase SDK / no config — this is a no-op and the /// `StockTickerAnalyticsSink` falls back to `NSLog`, mirroring the Android purefoss/dev flavours. diff --git a/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/di/IosModule.kt b/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/di/IosModule.kt index 04e2202e..0f821970 100644 --- a/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/di/IosModule.kt +++ b/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/di/IosModule.kt @@ -231,11 +231,26 @@ object KoinHelper : KoinComponent { private val portfolioExchange: IosPortfolioExchange by inject() private val widgetSnapshotStore: WidgetSnapshotStore by inject() private val clock: AppClock by inject() + private val appPreferences: UserDefaultsPreferences by inject() fun stocksProvider(): StocksProvider = stocksProvider fun refreshScheduler(): BackgroundRefreshScheduler = refreshScheduler fun analytics(): Analytics = analytics + /** + * The user's selected refresh interval in milliseconds. The iOS app uses it to drive the + * foreground refresh loop and the background-task cadence so quotes/widgets update at the + * interval the user picked in Settings. + */ + fun updateIntervalMillis(): Long = appPreferences.updateIntervalMs + + /** + * The scheduled epoch time (ms) of the next quotes refresh, as decided by the shared scheduler. + * The iOS foreground refresh loop uses it to fetch exactly when a refresh is due instead of + * hammering the network on every foreground. + */ + fun nextFetchMillis(): Long = stocksProvider.nextFetchMs.value + /** * The shared local-notifications handler (price alerts + daily summary). The iOS app starts its * refresh-state observer and requests notification authorization through [initializeNotifications]. @@ -261,7 +276,11 @@ object KoinHelper : KoinComponent { * reload), the iOS analogue of Android's `WidgetDataProvider` update. */ fun writeWidgetSnapshot() { - widgetSnapshotStore.write(stocksProvider.portfolio.value, clock.currentTimeMillis()) + widgetSnapshotStore.write( + quotes = stocksProvider.widgetOrderedQuotes(), + lastUpdatedMillis = clock.currentTimeMillis(), + updateIntervalMillis = appPreferences.updateIntervalMs, + ) } /** diff --git a/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/model/StocksProvider.kt b/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/model/StocksProvider.kt index e7191fad..5453470f 100644 --- a/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/model/StocksProvider.kt +++ b/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/model/StocksProvider.kt @@ -108,6 +108,22 @@ class StocksProvider( ) } + /** + * The watchlist quotes in their saved ticker order, ignoring the global auto-sort preference. + * + * The WidgetKit home-screen widget snapshot uses this so each placed widget can apply its own + * "Sort by change" toggle: the snapshot always carries the raw watchlist order, and the widget — + * not the app's global auto-sort setting — decides whether to sort. This keeps the per-widget + * toggle authoritative (enabling it sorts, disabling it shows the watchlist order). + */ + fun widgetOrderedQuotes(): List = lock.withLock { + buildWatchlistQuotes( + tickers = tickerSet, + quotesBySymbol = quoteMap, + autoSort = false + ) + } + private fun saveTickers() = storage.saveTickers(tickerSet) fun rearrange(tickers: List) { diff --git a/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/widget/WidgetSnapshotStore.kt b/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/widget/WidgetSnapshotStore.kt index ed032ced..97baeba7 100644 --- a/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/widget/WidgetSnapshotStore.kt +++ b/shared/src/iosMain/kotlin/com/github/premnirmal/ticker/widget/WidgetSnapshotStore.kt @@ -29,6 +29,12 @@ data class WidgetQuoteSnapshot( data class WidgetSnapshot( val quotes: List, val lastUpdatedMillis: Long, + /** + * The user's selected refresh interval in milliseconds. The widget uses it to schedule its next + * timeline reload, so a home-screen widget refreshes at the same cadence the user picked in the + * app instead of a fixed fallback. Defaults to 0 for snapshots written by older app versions. + */ + val updateIntervalMillis: Long = 0L, ) /** @@ -50,10 +56,11 @@ class WidgetSnapshotStore( ) { /** Serialize the current [quotes] and store them for the widget extension to read. */ - fun write(quotes: List, lastUpdatedMillis: Long) { + fun write(quotes: List, lastUpdatedMillis: Long, updateIntervalMillis: Long = 0L) { val snapshot = WidgetSnapshot( quotes = quotes.map { it.toWidgetSnapshot() }, lastUpdatedMillis = lastUpdatedMillis, + updateIntervalMillis = updateIntervalMillis, ) runCatching { json.encodeToString(snapshot) } .onSuccess { defaults.setObject(it, SNAPSHOT_KEY) } @@ -72,8 +79,8 @@ class WidgetSnapshotStore( symbol = symbol, name = name, price = priceString(), - changePercent = changePercentStringWithSign(), - changeAmount = changeStringWithSign(), + changePercent = changePercentString(), + changeAmount = changeString(), changeInPercent = changeInPercent, positive = change >= 0f, )