5d7d1e5487
* 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>
258 lines
11 KiB
Swift
258 lines
11 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
import AppIntents
|
|
import Shared
|
|
|
|
/// The data a single widget timeline entry renders. Built from the shared `WidgetSnapshot` written by
|
|
/// the app to the App Group store (`WidgetSnapshotStore`), filtered/sorted per the widget's own
|
|
/// `StockTickerConfigurationIntent`.
|
|
struct StockTickerEntry: TimelineEntry {
|
|
let date: Date
|
|
let quotes: [WidgetQuoteRow]
|
|
let isPlaceholder: Bool
|
|
/// The per-widget configuration controlling appearance (header, change amount, bold, …).
|
|
let configuration: StockTickerConfigurationIntent
|
|
}
|
|
|
|
/// A flattened, Swift-value copy of the shared `WidgetQuoteSnapshot` (so SwiftUI views don't hold
|
|
/// Kotlin objects).
|
|
struct WidgetQuoteRow: Identifiable {
|
|
let id = UUID()
|
|
let symbol: String
|
|
let name: String
|
|
let price: String
|
|
let changePercent: String
|
|
let changeAmount: String
|
|
let changeInPercent: Double
|
|
let positive: Bool
|
|
}
|
|
|
|
/// Reads the shared App Group snapshot and supplies the WidgetKit timeline, applying each widget
|
|
/// instance's own `StockTickerConfigurationIntent` (watchlist selection + appearance).
|
|
///
|
|
/// The widget extension runs in its own process, so it cannot reach the app's Koin graph / Room
|
|
/// database. It instead reads the compact JSON snapshot the app persists after every refresh through
|
|
/// the shared `WidgetSnapshotStore` (the iOS counterpart of Android's `WidgetDataProvider`).
|
|
struct StockTickerProvider: AppIntentTimelineProvider {
|
|
|
|
func placeholder(in context: Context) -> StockTickerEntry {
|
|
StockTickerEntry(date: Date(), quotes: Self.sampleRows, isPlaceholder: true,
|
|
configuration: StockTickerConfigurationIntent())
|
|
}
|
|
|
|
func snapshot(for configuration: StockTickerConfigurationIntent, in context: Context) async -> StockTickerEntry {
|
|
loadEntry(for: configuration)
|
|
}
|
|
|
|
func timeline(for configuration: StockTickerConfigurationIntent, in context: Context) async -> Timeline<StockTickerEntry> {
|
|
let entry = loadEntry(for: configuration)
|
|
// 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
|
|
WidgetQuoteRow(
|
|
symbol: quote.symbol,
|
|
name: quote.name,
|
|
price: quote.price,
|
|
changePercent: quote.changePercent,
|
|
changeAmount: quote.changeAmount,
|
|
changeInPercent: Double(quote.changeInPercent),
|
|
positive: quote.positive
|
|
)
|
|
}
|
|
// Per-widget watchlist selection: keep only the chosen symbols (or all when none selected).
|
|
if let selected = configuration.selectedSymbols {
|
|
rows = rows.filter { selected.contains($0.symbol) }
|
|
}
|
|
// 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 { $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)
|
|
}
|
|
|
|
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),
|
|
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),
|
|
]
|
|
}
|
|
|
|
// MARK: - Views
|
|
|
|
/// One watchlist row: symbol + price on the leading edge, change% (coloured) on the trailing edge.
|
|
private struct QuoteRowView: View {
|
|
let row: WidgetQuoteRow
|
|
let configuration: StockTickerConfigurationIntent
|
|
|
|
var body: some View {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(row.symbol)
|
|
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
|
.lineLimit(1)
|
|
Text(row.price)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 4)
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
Text(row.changePercent)
|
|
.font(.system(.caption, design: .rounded)
|
|
.weight(configuration.boldChange ? .bold : .medium))
|
|
.foregroundStyle(row.positive ? Color.green : Color.red)
|
|
.lineLimit(1)
|
|
Text(row.changeAmount)
|
|
.font(.caption2)
|
|
.foregroundStyle(row.positive ? Color.green : Color.red)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Two-column grid layout matching the Android widget. Shows a limited number of quotes to avoid
|
|
/// clipping in each widget size (iOS widgets do not support scrolling).
|
|
///
|
|
/// The rows are distributed across the full available height so the widget doesn't leave blank
|
|
/// space at the bottom; the quotes fill the whole widget instead of clustering at the top.
|
|
private struct StockTickerGridView: View {
|
|
let entry: StockTickerEntry
|
|
let columns: Int
|
|
let maxItems: Int
|
|
|
|
init(entry: StockTickerEntry, columns: Int = 2, maxItems: Int = 16) {
|
|
self.entry = entry
|
|
self.columns = columns
|
|
self.maxItems = maxItems
|
|
}
|
|
|
|
/// Chunk the (capped) quotes into rows of `columns` items each.
|
|
private var quoteRows: [[WidgetQuoteRow]] {
|
|
let items = Array(entry.quotes.prefix(maxItems))
|
|
return stride(from: 0, to: items.count, by: columns).map {
|
|
Array(items[$0 ..< min($0 + columns, items.count)])
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if entry.quotes.isEmpty {
|
|
EmptyWatchlistView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
} else {
|
|
let rows = quoteRows
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(rows.enumerated()), id: \.offset) { index, row in
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
ForEach(row) { quote in
|
|
QuoteRowView(row: quote, configuration: entry.configuration)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
// Keep column widths aligned when the last row is not full.
|
|
if row.count < columns {
|
|
ForEach(0 ..< (columns - row.count), id: \.self) { _ in
|
|
Color.clear.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
// Even gaps between rows fill the height so no blank space is left at the bottom.
|
|
if index < rows.count - 1 {
|
|
Spacer(minLength: 2)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct EmptyWatchlistView: View {
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Stocks Widget")
|
|
.font(.headline)
|
|
Text("Open the app and add symbols to your watchlist.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Routes the entry to the right layout for the current widget family.
|
|
struct StockTickerWidgetEntryView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
let entry: StockTickerEntry
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch family {
|
|
case .systemSmall:
|
|
StockTickerGridView(entry: entry, columns: 1, maxItems: 6)
|
|
case .systemMedium:
|
|
StockTickerGridView(entry: entry, columns: 2, maxItems: 10)
|
|
default:
|
|
StockTickerGridView(entry: entry, columns: 2, maxItems: 24)
|
|
}
|
|
}
|
|
.containerBackgroundCompat()
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
/// Applies the WidgetKit container background required on iOS 17+, no-op on earlier versions.
|
|
@ViewBuilder
|
|
func containerBackgroundCompat() -> some View {
|
|
if #available(iOS 17.0, *) {
|
|
self.containerBackground(.fill.tertiary, for: .widget)
|
|
} else {
|
|
self.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget
|
|
|
|
struct StockTickerWidget: Widget {
|
|
private let kind = "StockTickerWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
AppIntentConfiguration(
|
|
kind: kind,
|
|
intent: StockTickerConfigurationIntent.self,
|
|
provider: StockTickerProvider()
|
|
) { entry in
|
|
StockTickerWidgetEntryView(entry: entry)
|
|
}
|
|
.configurationDisplayName("Stocks Widget")
|
|
.description("Track your watchlist symbols at a glance.")
|
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
|
}
|
|
}
|