Add background task for overnight widget data refresh

- Create BackgroundTaskManager that schedules refresh at random time
  between 12:00 AM - 4:00 AM local time
- Fetches tasks from API, writes to DataManager and shared App Group
- Reloads widget timelines after refresh
- Register task in AppDelegate, schedule when app goes to background
- Add BGTaskSchedulerPermittedIdentifiers and fetch/processing modes to Info.plist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-23 20:36:17 -06:00
parent 4daaa1f7d8
commit 556b187508
5 changed files with 256 additions and 41 deletions

View File

@@ -675,6 +675,8 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "Casera needs access to your camera to take photos of completed tasks";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Casera needs access to your photo library to select photos of completed tasks";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -1124,6 +1126,8 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "Casera needs access to your camera to take photos of completed tasks";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Casera needs access to your photo library to select photos of completed tasks";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;

View File

@@ -0,0 +1,202 @@
//
// BackgroundTaskManager.swift
// iosApp
//
// Manages background task scheduling for overnight task data refresh.
// Runs at a random time between 12:00 AM and 4:00 AM local time.
//
import Foundation
import BackgroundTasks
import WidgetKit
import ComposeApp
/// Manages background app refresh to keep task data fresh overnight
final class BackgroundTaskManager {
static let shared = BackgroundTaskManager()
/// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers
static let taskIdentifier = "com.tt.casera.refresh"
/// Time window for overnight refresh (12:00 AM - 4:00 AM)
private let refreshWindowStartHour = 0 // 12:00 AM
private let refreshWindowEndHour = 4 // 4:00 AM
private init() {}
// MARK: - Registration
/// Register background tasks with the system. Call this in AppDelegate didFinishLaunchingWithOptions.
func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.taskIdentifier,
using: nil
) { [weak self] task in
guard let bgTask = task as? BGAppRefreshTask else { return }
self?.handleAppRefresh(task: bgTask)
}
print("BackgroundTaskManager: Registered background task \(Self.taskIdentifier)")
}
// MARK: - Scheduling
/// Schedule the next background refresh. Call this when the app goes to background.
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
// Calculate a random time between 12:00 AM and 4:00 AM
request.earliestBeginDate = calculateNextRefreshDate()
do {
try BGTaskScheduler.shared.submit(request)
print("BackgroundTaskManager: Scheduled background refresh for \(request.earliestBeginDate?.description ?? "unknown")")
} catch {
print("BackgroundTaskManager: Failed to schedule background refresh - \(error.localizedDescription)")
}
}
/// Cancel any pending background refresh requests
func cancelPendingRefresh() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
print("BackgroundTaskManager: Cancelled pending background refresh")
}
// MARK: - Task Handling
/// Handle the background app refresh task
private func handleAppRefresh(task: BGAppRefreshTask) {
print("BackgroundTaskManager: Starting background refresh at \(Date())")
// Schedule the next refresh before we start (in case this one fails)
scheduleAppRefresh()
// Create a task to fetch data
let refreshTask = Task {
await performDataRefresh()
}
// Set up expiration handler
task.expirationHandler = {
print("BackgroundTaskManager: Task expired, cancelling")
refreshTask.cancel()
}
// Wait for the refresh to complete
Task {
let success = await refreshTask.value
task.setTaskCompleted(success: success)
print("BackgroundTaskManager: Background refresh completed with success=\(success)")
}
}
/// Perform the actual data refresh
@MainActor
private func performDataRefresh() async -> Bool {
// Check if user is authenticated
guard let token = TokenStorage.shared.getToken(), !token.isEmpty else {
print("BackgroundTaskManager: No auth token, skipping refresh")
return true // Not a failure, just nothing to do
}
do {
// Fetch tasks from API
print("BackgroundTaskManager: Fetching tasks from API...")
let result = try await APILayer.shared.getTasks(forceRefresh: true)
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
let data = success.data {
// Write to DataManager (memory) - this happens automatically in APILayer
print("BackgroundTaskManager: Tasks loaded into DataManager")
// Write to shared App Group for widget
WidgetDataManager.shared.saveTasks(from: data)
print("BackgroundTaskManager: Tasks saved to shared App Group")
// Update widget
WidgetCenter.shared.reloadAllTimelines()
print("BackgroundTaskManager: Widget timelines reloaded")
return true
} else if let error = result as? ApiResultError {
print("BackgroundTaskManager: API error - \(error.message)")
return false
}
return true
} catch {
print("BackgroundTaskManager: Error during refresh - \(error.localizedDescription)")
return false
}
}
// MARK: - Time Calculation
/// Calculate the next refresh date (random time between 12:00 AM and 4:00 AM)
private func calculateNextRefreshDate() -> Date {
let calendar = Calendar.current
let now = Date()
// Get today's date components
var components = calendar.dateComponents([.year, .month, .day], from: now)
// Determine if we should schedule for tonight or tomorrow night
let currentHour = calendar.component(.hour, from: now)
// If it's already past midnight (past the refresh window), schedule for tomorrow night
if currentHour >= refreshWindowEndHour {
// Add one day to schedule for tomorrow's window
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) {
components = calendar.dateComponents([.year, .month, .day], from: tomorrow)
}
}
// Generate random hour (0-3) and minute (0-59) within the refresh window
let randomHour = Int.random(in: refreshWindowStartHour..<refreshWindowEndHour)
let randomMinute = Int.random(in: 0..<60)
let randomSecond = Int.random(in: 0..<60)
components.hour = randomHour
components.minute = randomMinute
components.second = randomSecond
// Create the scheduled date
if let scheduledDate = calendar.date(from: components) {
// Make sure it's in the future
if scheduledDate > now {
return scheduledDate
} else {
// If somehow in the past, add a day
return calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate
}
}
// Fallback: schedule for 2 hours from now
return now.addingTimeInterval(2 * 60 * 60)
}
// MARK: - Debug Helpers
/// Get a description of the current scheduling state (for debugging)
func getSchedulingStatus() -> String {
let nextDate = calculateNextRefreshDate()
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return "Next refresh scheduled for approximately: \(formatter.string(from: nextDate))"
}
/// Force a background refresh for testing (only works in debug builds with Xcode)
/// Usage: In Xcode debugger console:
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.tt.casera.refresh"]
func debugInfo() -> String {
return """
Background Task Debug Info:
- Task ID: \(Self.taskIdentifier)
- Refresh Window: \(refreshWindowStartHour):00 - \(refreshWindowEndHour):00
- \(getSchedulingStatus())
To test in Xcode debugger:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"\(Self.taskIdentifier)"]
"""
}
}

View File

@@ -2,45 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.tt.casera.refresh</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.casera.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>casera</string>
</array>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<false/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -56,20 +23,55 @@
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.casera.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>casera</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.casera.contractor</string>
<key>UTTypeDescription</key>
<string>Casera Contractor</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeDescription</key>
<string>Casera Contractor</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>com.casera.contractor</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>

View File

@@ -1,5 +1,6 @@
import UIKit
import UserNotifications
import BackgroundTasks
import ComposeApp
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
@@ -14,6 +15,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
// Register notification categories for actionable notifications
NotificationCategories.registerCategories()
// Register background tasks for overnight data refresh
BackgroundTaskManager.shared.registerBackgroundTasks()
// Request notification permission
Task { @MainActor in
await PushNotificationManager.shared.requestNotificationPermission()

View File

@@ -92,6 +92,9 @@ struct iOSApp: App {
} else if newPhase == .background {
// Refresh widget when app goes to background
WidgetCenter.shared.reloadAllTimelines()
// Schedule overnight background refresh (12am-4am)
BackgroundTaskManager.shared.scheduleAppRefresh()
}
}
// Import confirmation dialog - routes to appropriate handler