Harden iOS app with audit fixes, UI consistency, and sheet race condition fixes

Applies verified fixes from deep audit (concurrency, performance, security,
accessibility), standardizes CRUD form buttons to Add/Save pattern, removes
.drawingGroup() that broke search bar TextFields, and converts vulnerable
.sheet(isPresented:) + if-let patterns to safe presentation to prevent
blank white modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-06 09:59:56 -06:00
parent 61ab95d108
commit 9c574c4343
76 changed files with 824 additions and 971 deletions
+1
View File
@@ -586,6 +586,7 @@ enum L10n {
// MARK: - Common
enum Common {
static var save: String { String(localized: "common_save") }
static var add: String { String(localized: "common_add") }
static var cancel: String { String(localized: "common_cancel") }
static var delete: String { String(localized: "common_delete") }
static var edit: String { String(localized: "common_edit") }
+2 -2
View File
@@ -44,7 +44,7 @@ struct ViewStateHandler<Content: View>: View {
content
}
}
.onChange(of: error) { errorMessage in
.onChange(of: error) { _, errorMessage in
if let errorMessage = errorMessage, !errorMessage.isEmpty {
errorAlert = ErrorAlertInfo(message: errorMessage)
}
@@ -93,7 +93,7 @@ private struct ErrorHandlerModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onChange(of: error) { errorMessage in
.onChange(of: error) { _, errorMessage in
if let errorMessage = errorMessage, !errorMessage.isEmpty {
errorAlert = ErrorAlertInfo(message: errorMessage)
}
@@ -8,6 +8,12 @@ import WidgetKit
final class WidgetActionProcessor {
static let shared = WidgetActionProcessor()
/// Maximum number of retry attempts per action before giving up
private static let maxRetries = 3
/// Tracks retry counts by action description (taskId)
private var retryCounts: [Int: Int] = [:]
private init() {}
/// Check if there are pending widget actions to process
@@ -65,23 +71,38 @@ final class WidgetActionProcessor {
if result is ApiResultSuccess<TaskCompletionResponse> {
print("WidgetActionProcessor: Task \(taskId) completed successfully")
// Remove the processed action
// Remove the processed action and clear pending state
WidgetDataManager.shared.removeAction(action)
// Clear pending state for this task
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
retryCounts.removeValue(forKey: taskId)
// Refresh tasks to update UI
await refreshTasks()
} else if let error = ApiResultBridge.error(from: result) {
print("WidgetActionProcessor: Failed to complete task \(taskId): \(error.message)")
// Remove action to avoid infinite retries
WidgetDataManager.shared.removeAction(action)
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
handleRetryOrDiscard(taskId: taskId, action: action, reason: error.message)
}
} catch {
print("WidgetActionProcessor: Error completing task \(taskId): \(error)")
// Remove action to avoid retries on error
handleRetryOrDiscard(taskId: taskId, action: action, reason: error.localizedDescription)
}
}
/// Increment retry count; discard action only after maxRetries.
/// On failure, clear pending state so the task reappears in the widget.
private func handleRetryOrDiscard(taskId: Int, action: WidgetDataManager.WidgetAction, reason: String) {
let attempts = (retryCounts[taskId] ?? 0) + 1
retryCounts[taskId] = attempts
if attempts >= Self.maxRetries {
print("WidgetActionProcessor: Task \(taskId) failed after \(attempts) attempts (\(reason)). Discarding action.")
WidgetDataManager.shared.removeAction(action)
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
retryCounts.removeValue(forKey: taskId)
} else {
print("WidgetActionProcessor: Task \(taskId) attempt \(attempts)/\(Self.maxRetries) failed (\(reason)). Keeping for retry.")
// Clear pending state so the task is visible in the widget again,
// but keep the action so it will be retried next time the app becomes active.
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
}
}
@@ -222,10 +222,14 @@ final class WidgetDataManager {
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
if FileManager.default.fileExists(atPath: fileURL.path) {
do {
let data = try Data(contentsOf: fileURL)
actions = try JSONDecoder().decode([WidgetAction].self, from: data)
} catch {
print("WidgetDataManager: Failed to decode pending actions: \(error)")
actions = []
}
} else {
actions = []
}