Fix iOS widget autosort inversion and drive updates at the selected interval (#489)
* 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:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
premnirmal
parent
8a74ad2428
commit
5d7d1e5487
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
+10
-3
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user