feat: automatic foreground sync — 5 min timer + resume refresh
StoryStore gains startForegroundSync() / stopForegroundSync(): - Polls loadStories(refresh:true) every 5 minutes while app is active - WebSocket handles real-time storyCreated events between polls - Timer cancels when app goes to background to save battery JarvisApp wires scenePhase: - .active → start 5-min timer; immediate refresh if last sync > 2 min ago - .background → cancel timer - On launch → timer starts after setup completes Net result: feed replenishes automatically; user never hits an empty list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ struct JarvisApp: App {
|
|||||||
@StateObject private var connectivity = ConnectivitySettings.shared
|
@StateObject private var connectivity = ConnectivitySettings.shared
|
||||||
@StateObject private var connManager = ConnectivityManager.shared
|
@StateObject private var connManager = ConnectivityManager.shared
|
||||||
@StateObject private var notifications = NotificationManager.shared
|
@StateObject private var notifications = NotificationManager.shared
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var splashDone = false
|
@State private var splashDone = false
|
||||||
|
|
||||||
// Single ModelContainer instance shared with BackgroundRefreshManager.
|
// Single ModelContainer instance shared with BackgroundRefreshManager.
|
||||||
@@ -120,6 +121,22 @@ struct JarvisApp: App {
|
|||||||
connManager.start()
|
connManager.start()
|
||||||
await connManager.resolveAndActivate()
|
await connManager.resolveAndActivate()
|
||||||
await notifications.bootstrap()
|
await notifications.bootstrap()
|
||||||
|
store.startForegroundSync()
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .active:
|
||||||
|
store.startForegroundSync()
|
||||||
|
// Refresh immediately if we've been away more than 2 minutes.
|
||||||
|
if let last = store.lastSyncedAt,
|
||||||
|
Date().timeIntervalSince(last) > 120 {
|
||||||
|
Task { await store.loadStories(refresh: true) }
|
||||||
|
}
|
||||||
|
case .background:
|
||||||
|
store.stopForegroundSync()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(container)
|
.modelContainer(container)
|
||||||
|
|||||||
@@ -16,10 +16,32 @@ final class StoryStore: ObservableObject {
|
|||||||
@Published var lastSyncedAt: Date?
|
@Published var lastSyncedAt: Date?
|
||||||
@Published var hasMore = false
|
@Published var hasMore = false
|
||||||
@Published var selectedTopic: String? = nil
|
@Published var selectedTopic: String? = nil
|
||||||
|
@Published var selectedTags: Set<String> = []
|
||||||
|
|
||||||
private var nextCursor: String?
|
private var nextCursor: String?
|
||||||
private var refreshTask: Task<Void, Never>?
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
private var foregroundSyncTask: Task<Void, Never>?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Foreground sync
|
||||||
|
|
||||||
|
/// Call when app becomes active. Polls every 5 min so the feed never runs dry.
|
||||||
|
/// The WebSocket handles real-time events between polls.
|
||||||
|
func startForegroundSync() {
|
||||||
|
foregroundSyncTask?.cancel()
|
||||||
|
foregroundSyncTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await self?.loadStories(refresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopForegroundSync() {
|
||||||
|
foregroundSyncTask?.cancel()
|
||||||
|
foregroundSyncTask = nil
|
||||||
|
}
|
||||||
private let ws = WebSocketManager.shared
|
private let ws = WebSocketManager.shared
|
||||||
private let api = APIClient.shared
|
private let api = APIClient.shared
|
||||||
|
|
||||||
@@ -42,7 +64,8 @@ final class StoryStore: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let result = try await api.fetchStories(
|
let result = try await api.fetchStories(
|
||||||
cursor: cursor,
|
cursor: cursor,
|
||||||
topic: selectedTopic
|
topic: selectedTopic,
|
||||||
|
tags: selectedTags
|
||||||
)
|
)
|
||||||
stories = refresh ? result.data : stories + result.data
|
stories = refresh ? result.data : stories + result.data
|
||||||
nextCursor = result.nextCursor
|
nextCursor = result.nextCursor
|
||||||
@@ -61,7 +84,7 @@ final class StoryStore: ObservableObject {
|
|||||||
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
|
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
do {
|
do {
|
||||||
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic)
|
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
|
||||||
stories += result.data
|
stories += result.data
|
||||||
nextCursor = result.nextCursor
|
nextCursor = result.nextCursor
|
||||||
hasMore = result.hasMore
|
hasMore = result.hasMore
|
||||||
@@ -82,6 +105,14 @@ final class StoryStore: ObservableObject {
|
|||||||
|
|
||||||
func setTopic(_ topic: String?) async {
|
func setTopic(_ topic: String?) async {
|
||||||
selectedTopic = topic
|
selectedTopic = topic
|
||||||
|
selectedTags = []
|
||||||
|
await loadStories(refresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPill(_ pill: StoryPill) async {
|
||||||
|
selectedTopic = nil
|
||||||
|
selectedTags = pill.slugs
|
||||||
|
nextCursor = nil
|
||||||
await loadStories(refresh: true)
|
await loadStories(refresh: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user