Add task completion animations, subscription trials, and quiet debug console

- Completion animations: play user-selected animation on task card after completing,
  with DataManager guard to prevent race condition during animation playback.
  Works in both AllTasksView and ResidenceDetailView. Animation preference
  persisted via @AppStorage and configurable from Settings.
- Subscription: add trial fields (trialStart, trialEnd, trialActive) and
  subscriptionSource to model, cross-platform purchase guard, trial banner
  in upgrade prompt, and platform-aware subscription management in profile.
- Analytics: disable PostHog SDK debug logging and remove console print
  statements to reduce debug console noise.
- Documents: remove redundant nested do-catch blocks in ViewModel wrapper.
- Widgets: add debounced timeline reloads and thread-safe file I/O queue.
- Onboarding: fix animation leak on disappear, remove unused state vars.
- Remove unused files (ContentView, StateFlowExtensions, CustomView).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-05 11:35:08 -06:00
parent c5f2bee83f
commit 98dbacdea0
73 changed files with 1770 additions and 529 deletions
+1
View File
@@ -7,6 +7,7 @@ enum DateUtils {
private static let isoDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
+1 -1
View File
@@ -39,7 +39,7 @@ enum UITestRuntime {
UserDefaults.standard.set(true, forKey: "ui_testing_mode")
}
static func resetStateIfRequested() {
@MainActor static func resetStateIfRequested() {
guard shouldResetState else { return }
DataManager.shared.clear()
@@ -23,16 +23,16 @@ final class WidgetActionProcessor {
return
}
let actions = WidgetDataManager.shared.loadPendingActions()
guard !actions.isEmpty else {
print("WidgetActionProcessor: No pending actions")
return
}
Task {
let actions = await WidgetDataManager.shared.loadPendingActions()
guard !actions.isEmpty else {
print("WidgetActionProcessor: No pending actions")
return
}
print("WidgetActionProcessor: Processing \(actions.count) pending action(s)")
print("WidgetActionProcessor: Processing \(actions.count) pending action(s)")
for action in actions {
Task {
for action in actions {
await processAction(action)
}
}
+181 -76
View File
@@ -7,6 +7,11 @@ import ComposeApp
final class WidgetDataManager {
static let shared = WidgetDataManager()
/// Tracks the last time `reloadAllTimelines()` was called for debouncing
private static var lastReloadTime: Date = .distantPast
/// Minimum interval between `reloadAllTimelines()` calls (seconds)
private static let reloadDebounceInterval: TimeInterval = 2.0
// MARK: - API Column Names (Single Source of Truth)
// These match the column names returned by the API's task columns endpoint
static let overdueColumn = "overdue_tasks"
@@ -25,19 +30,31 @@ final class WidgetDataManager {
private let limitationsEnabledKey = "widget_limitations_enabled"
private let isPremiumKey = "widget_is_premium"
/// Serial queue for thread-safe file I/O operations
private let fileQueue = DispatchQueue(label: "com.casera.widget.fileio")
private var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: appGroupIdentifier)
}
private init() {}
/// Reload all widget timelines, debounced to avoid excessive reloads.
/// Only triggers a reload if at least `reloadDebounceInterval` has elapsed since the last reload.
private func reloadWidgetTimelinesIfNeeded() {
let now = Date()
if now.timeIntervalSince(Self.lastReloadTime) >= Self.reloadDebounceInterval {
Self.lastReloadTime = now
WidgetCenter.shared.reloadAllTimelines()
}
}
// MARK: - Auth Token Sharing
/// Save auth token to shared App Group for widget access
/// Call this after successful login or when token is refreshed
func saveAuthToken(_ token: String) {
sharedDefaults?.set(token, forKey: tokenKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Saved auth token to shared container")
}
@@ -51,14 +68,12 @@ final class WidgetDataManager {
/// Call this on logout
func clearAuthToken() {
sharedDefaults?.removeObject(forKey: tokenKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Cleared auth token from shared container")
}
/// Save API base URL to shared container for widget
func saveAPIBaseURL(_ url: String) {
sharedDefaults?.set(url, forKey: apiBaseURLKey)
sharedDefaults?.synchronize()
}
/// Get API base URL from shared container
@@ -73,7 +88,6 @@ final class WidgetDataManager {
func saveSubscriptionStatus(limitationsEnabled: Bool, isPremium: Bool) {
sharedDefaults?.set(limitationsEnabled, forKey: limitationsEnabledKey)
sharedDefaults?.set(isPremium, forKey: isPremiumKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Saved subscription status - limitations=\(limitationsEnabled), premium=\(isPremium)")
// Reload widget to reflect new subscription status
WidgetCenter.shared.reloadAllTimelines()
@@ -104,7 +118,6 @@ final class WidgetDataManager {
/// Called by widget after completing a task
func markTasksDirty() {
sharedDefaults?.set(true, forKey: dirtyFlagKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Marked tasks as dirty")
}
@@ -116,7 +129,6 @@ final class WidgetDataManager {
/// Clear dirty flag after refreshing tasks
func clearDirtyFlag() {
sharedDefaults?.set(false, forKey: dirtyFlagKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Cleared dirty flag")
}
@@ -142,56 +154,100 @@ final class WidgetDataManager {
// MARK: - Pending Action Processing
/// Load pending actions queued by the widget
func loadPendingActions() -> [WidgetAction] {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName),
FileManager.default.fileExists(atPath: fileURL.path) else {
/// Load pending actions queued by the widget (async, non-blocking)
func loadPendingActions() async -> [WidgetAction] {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
return []
}
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([WidgetAction].self, from: data)
} catch {
print("WidgetDataManager: Error loading pending actions - \(error)")
return await withCheckedContinuation { continuation in
fileQueue.async {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
continuation.resume(returning: [])
return
}
do {
let data = try Data(contentsOf: fileURL)
let actions = try JSONDecoder().decode([WidgetAction].self, from: data)
continuation.resume(returning: actions)
} catch {
print("WidgetDataManager: Error loading pending actions - \(error)")
continuation.resume(returning: [])
}
}
}
}
/// Load pending actions synchronously (blocks calling thread).
/// Prefer the async overload from the main app. This is kept for widget extension use.
func loadPendingActionsSync() -> [WidgetAction] {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
return []
}
return fileQueue.sync {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return []
}
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([WidgetAction].self, from: data)
} catch {
print("WidgetDataManager: Error loading pending actions - \(error)")
return []
}
}
}
/// Clear all pending actions after processing
func clearPendingActions() {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared pending actions")
} catch {
// File might not exist
fileQueue.async {
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared pending actions")
} catch {
// File might not exist
}
}
}
/// Remove a specific action after processing
func removeAction(_ action: WidgetAction) {
var actions = loadPendingActions()
actions.removeAll { $0 == action }
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
if actions.isEmpty {
clearPendingActions()
} else {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
do {
let data = try JSONEncoder().encode(actions)
try data.write(to: fileURL, options: .atomic)
} catch {
print("WidgetDataManager: Error saving actions - \(error)")
fileQueue.async {
// Load actions within the serial queue to avoid race conditions
var actions: [WidgetAction]
if FileManager.default.fileExists(atPath: fileURL.path),
let data = try? Data(contentsOf: fileURL),
let decoded = try? JSONDecoder().decode([WidgetAction].self, from: data) {
actions = decoded
} else {
actions = []
}
actions.removeAll { $0 == action }
if actions.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
} else {
do {
let data = try JSONEncoder().encode(actions)
try data.write(to: fileURL, options: .atomic)
} catch {
print("WidgetDataManager: Error saving actions - \(error)")
}
}
}
}
/// Clear pending state for a task after it's been synced
func clearPendingState(forTaskId taskId: Int) {
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName),
FileManager.default.fileExists(atPath: fileURL.path) else {
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName) else {
return
}
@@ -201,28 +257,36 @@ final class WidgetDataManager {
let timestamp: Date
}
do {
let data = try Data(contentsOf: fileURL)
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
states.removeAll { $0.taskId == taskId }
fileQueue.async {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return
}
if states.isEmpty {
try FileManager.default.removeItem(at: fileURL)
} else {
let updatedData = try JSONEncoder().encode(states)
try updatedData.write(to: fileURL, options: .atomic)
do {
let data = try Data(contentsOf: fileURL)
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
states.removeAll { $0.taskId == taskId }
if states.isEmpty {
try FileManager.default.removeItem(at: fileURL)
} else {
let updatedData = try JSONEncoder().encode(states)
try updatedData.write(to: fileURL, options: .atomic)
}
} catch {
print("WidgetDataManager: Error clearing pending state - \(error)")
}
// Reload widget to reflect the change
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
} catch {
print("WidgetDataManager: Error clearing pending state - \(error)")
DispatchQueue.main.async {
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
}
}
}
/// Check if there are any pending actions from the widget
var hasPendingActions: Bool {
!loadPendingActions().isEmpty
!loadPendingActionsSync().isEmpty
}
/// Task model for widget display - simplified version of TaskDetail
@@ -285,6 +349,7 @@ final class WidgetDataManager {
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
@@ -364,47 +429,82 @@ final class WidgetDataManager {
}
}
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(allTasks)
try data.write(to: fileURL, options: .atomic)
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
fileQueue.async {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(allTasks)
try data.write(to: fileURL, options: .atomic)
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
} catch {
print("WidgetDataManager: Error saving tasks - \(error)")
}
// Reload widget timeline
WidgetCenter.shared.reloadAllTimelines()
} catch {
print("WidgetDataManager: Error saving tasks - \(error)")
// Reload widget timeline (debounced) after file write completes
DispatchQueue.main.async {
self.reloadWidgetTimelinesIfNeeded()
}
}
}
/// Load tasks from the shared container
/// Used by the widget to read cached data
func loadTasks() -> [WidgetTask] {
/// Load tasks from the shared container (async, non-blocking)
func loadTasks() async -> [WidgetTask] {
guard let fileURL = tasksFileURL else {
print("WidgetDataManager: Unable to access shared container")
return []
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("WidgetDataManager: No cached tasks file found")
return await withCheckedContinuation { continuation in
fileQueue.async {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("WidgetDataManager: No cached tasks file found")
continuation.resume(returning: [])
return
}
do {
let data = try Data(contentsOf: fileURL)
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
continuation.resume(returning: tasks)
} catch {
print("WidgetDataManager: Error loading tasks - \(error)")
continuation.resume(returning: [])
}
}
}
}
/// Load tasks synchronously (blocks calling thread).
/// Prefer the async overload from the main app. This is kept for widget extension use.
func loadTasksSync() -> [WidgetTask] {
guard let fileURL = tasksFileURL else {
print("WidgetDataManager: Unable to access shared container")
return []
}
do {
let data = try Data(contentsOf: fileURL)
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
return tasks
} catch {
print("WidgetDataManager: Error loading tasks - \(error)")
return []
return fileQueue.sync {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("WidgetDataManager: No cached tasks file found")
return []
}
do {
let data = try Data(contentsOf: fileURL)
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
return tasks
} catch {
print("WidgetDataManager: Error loading tasks - \(error)")
return []
}
}
}
/// Get upcoming/pending tasks for widget display
/// Uses synchronous loading since this is typically called from widget timeline providers
func getUpcomingTasks() -> [WidgetTask] {
let allTasks = loadTasks()
let allTasks = loadTasksSync()
// All loaded tasks are already filtered (archived and completed columns are excluded during save)
// Sort by due date (earliest first), with overdue at top
@@ -426,12 +526,17 @@ final class WidgetDataManager {
func clearCache() {
guard let fileURL = tasksFileURL else { return }
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared widget cache")
WidgetCenter.shared.reloadAllTimelines()
} catch {
print("WidgetDataManager: Error clearing cache - \(error)")
fileQueue.async {
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared widget cache")
} catch {
print("WidgetDataManager: Error clearing cache - \(error)")
}
DispatchQueue.main.async {
WidgetCenter.shared.reloadAllTimelines()
}
}
}