Fix iOS widget autosort inversion and drive updates at the selected interval (#489)
Android / Build and release (push) Canceled after 0s
Detekt / Run Detekt (push) Canceled after 0s
iOS / Build iOS framework, app & run shared tests (push) Canceled after 0s
Run unit tests / Run Unit Tests (push) Canceled after 0s

* Fix iOS widget autosort inversion and add interval-based aggressive updates

Co-authored-by: premnirmal <1255689+premnirmal@users.noreply.github.com>

* Remove '+' prefix from positive changes in iOS widget

Co-authored-by: premnirmal <1255689+premnirmal@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: premnirmal <1255689+premnirmal@users.noreply.github.com>
This commit is contained in:
Copilot
2026-07-31 15:16:40 +01:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> premnirmal
parent 8a74ad2428
commit 5d7d1e5487
6 changed files with 161 additions and 10 deletions
@@ -46,11 +46,25 @@ struct StockTickerProvider: AppIntentTimelineProvider {
func timeline(for configuration: StockTickerConfigurationIntent, in context: Context) async -> Timeline<StockTickerEntry> {
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),
]
}
@@ -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<Void, Never>?
/// 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)
}
}
+30
View File
@@ -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.
@@ -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,
)
}
/**
@@ -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<Quote> = lock.withLock {
buildWatchlistQuotes(
tickers = tickerSet,
quotesBySymbol = quoteMap,
autoSort = false
)
}
private fun saveTickers() = storage.saveTickers(tickerSet)
fun rearrange(tickers: List<String>) {
@@ -29,6 +29,12 @@ data class WidgetQuoteSnapshot(
data class WidgetSnapshot(
val quotes: List<WidgetQuoteSnapshot>,
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<Quote>, lastUpdatedMillis: Long) {
fun write(quotes: List<Quote>, 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,
)