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

View File

@@ -40,13 +40,7 @@ struct CompleteTaskIntent: AppIntent {
func perform() async throws -> some IntentResult {
print("CompleteTaskIntent: Starting completion for task \(taskId)")
// Mark task as pending completion immediately (optimistic UI)
WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId)
// Reload widget immediately to update task list and stats
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
// Get auth token and API URL from shared container
// Check auth BEFORE marking pending if auth fails the task should remain visible
guard let token = WidgetActionManager.shared.getAuthToken() else {
print("CompleteTaskIntent: No auth token available")
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
@@ -59,6 +53,12 @@ struct CompleteTaskIntent: AppIntent {
return .result()
}
// Mark task as pending completion (optimistic UI) only after auth is confirmed
WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId)
// Reload widget immediately to update task list and stats
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
// Make API call to complete the task
let success = await WidgetAPIClient.quickCompleteTask(
taskId: taskId,

View File

@@ -12,7 +12,5 @@ import SwiftUI
struct CaseraBundle: WidgetBundle {
var body: some Widget {
Casera()
CaseraControl()
CaseraLiveActivity()
}
}

View File

@@ -1,77 +0,0 @@
//
// CaseraControl.swift
// Casera
//
// Created by Trey Tartt on 11/5/25.
//
import AppIntents
import SwiftUI
import WidgetKit
struct CaseraControl: ControlWidget {
static let kind: String = "com.example.casera.Casera.Casera"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension CaseraControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
CaseraControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return CaseraControl.Value(isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -1,80 +0,0 @@
//
// CaseraLiveActivity.swift
// Casera
//
// Created by Trey Tartt on 11/5/25.
//
import ActivityKit
import WidgetKit
import SwiftUI
struct CaseraAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var emoji: String
}
// Fixed non-changing properties about your activity go here!
var name: String
}
struct CaseraLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: CaseraAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
Text("Hello \(context.state.emoji)")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom \(context.state.emoji)")
// more content
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T \(context.state.emoji)")
} minimal: {
Text(context.state.emoji)
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}
extension CaseraAttributes {
fileprivate static var preview: CaseraAttributes {
CaseraAttributes(name: "World")
}
}
extension CaseraAttributes.ContentState {
fileprivate static var smiley: CaseraAttributes.ContentState {
CaseraAttributes.ContentState(emoji: "😀")
}
fileprivate static var starEyes: CaseraAttributes.ContentState {
CaseraAttributes.ContentState(emoji: "🤩")
}
}
#Preview("Notification", as: .content, using: CaseraAttributes.preview) {
CaseraLiveActivity()
} contentStates: {
CaseraAttributes.ContentState.smiley
CaseraAttributes.ContentState.starEyes
}

View File

@@ -10,6 +10,28 @@ import SwiftUI
import AppIntents
// MARK: - Date Formatting Helper
/// Cached formatters to avoid repeated allocation in widget rendering
private enum WidgetDateFormatters {
static let dateOnly: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
static let iso8601WithFractional: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
static let iso8601: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
}
/// Parses date strings in either yyyy-MM-dd or ISO8601 (RFC3339) format
/// and returns a user-friendly string like "Today" or "in X days"
private func formatWidgetDate(_ dateString: String) -> String {
@@ -17,20 +39,15 @@ private func formatWidgetDate(_ dateString: String) -> String {
var date: Date?
// Try parsing as yyyy-MM-dd first
let dateOnlyFormatter = DateFormatter()
dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
date = dateOnlyFormatter.date(from: dateString)
date = WidgetDateFormatters.dateOnly.date(from: dateString)
// Try parsing as ISO8601 (RFC3339) if that fails
if date == nil {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
date = isoFormatter.date(from: dateString)
date = WidgetDateFormatters.iso8601WithFractional.date(from: dateString)
// Try without fractional seconds
if date == nil {
isoFormatter.formatOptions = [.withInternetDateTime]
date = isoFormatter.date(from: dateString)
date = WidgetDateFormatters.iso8601.date(from: dateString)
}
}
@@ -179,9 +196,11 @@ struct Provider: AppIntentTimelineProvider {
let tasks = CacheManager.getUpcomingTasks()
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
// Update every 30 minutes (more frequent for interactive widgets)
// Use a longer refresh interval during overnight hours (11pm-6am)
let currentDate = Date()
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)!
let hour = Calendar.current.component(.hour, from: currentDate)
let refreshMinutes = (hour >= 23 || hour < 6) ? 120 : 30
let nextUpdate = Calendar.current.date(byAdding: .minute, value: refreshMinutes, to: currentDate)!
let entry = SimpleEntry(
date: currentDate,
configuration: configuration,

View File

@@ -198,16 +198,15 @@ class PreviewViewController: UIViewController, QLPreviewingController {
func preparePreviewOfFile(at url: URL) async throws {
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)")
// Parse the .casera file
// Parse the .casera file single Codable pass to detect type, then decode
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
// Detect package type first
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String,
typeString == "residence" {
let envelope = try? decoder.decode(PackageTypeEnvelope.self, from: data)
if envelope?.type == "residence" {
currentPackageType = .residence
let decoder = JSONDecoder()
let residence = try decoder.decode(ResidencePreviewData.self, from: data)
self.residenceData = residence
print("CaseraQLPreview: Parsed residence: \(residence.residenceName)")
@@ -218,7 +217,6 @@ class PreviewViewController: UIViewController, QLPreviewingController {
} else {
currentPackageType = .contractor
let decoder = JSONDecoder()
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
self.contractorData = contractor
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
@@ -287,6 +285,13 @@ class PreviewViewController: UIViewController, QLPreviewingController {
}
}
// MARK: - Type Discriminator
/// Lightweight struct to detect the package type without a full parse
private struct PackageTypeEnvelope: Decodable {
let type: String?
}
// MARK: - Data Model
struct ContractorPreviewData: Codable {

View File

@@ -54,13 +54,17 @@ class ThumbnailProvider: QLThumbnailProvider {
}), nil)
}
/// Lightweight struct to detect the package type via Codable instead of JSONSerialization
private struct PackageTypeEnvelope: Decodable {
let type: String?
}
/// Detects the package type by reading the "type" field from the JSON
private func detectPackageType(at url: URL) -> PackageType {
do {
let data = try Data(contentsOf: url)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String,
typeString == "residence" {
let envelope = try JSONDecoder().decode(PackageTypeEnvelope.self, from: data)
if envelope.type == "residence" {
return .residence
}
} catch {

View File

@@ -230,7 +230,7 @@ final class AnalyticsManager {
var sessionReplayEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil {
return true
return false
}
return UserDefaults.standard.bool(forKey: Self.sessionReplayKey)
}

View File

@@ -90,7 +90,6 @@ final class BackgroundTaskManager {
}
/// 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 {

View File

@@ -155,6 +155,7 @@ private class AuthenticatedImageLoader: ObservableObject {
// Create request with auth header
var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 15
request.cachePolicy = .returnCacheDataElseLoad
do {

View File

@@ -11,7 +11,6 @@ struct ContractorDetailView: View {
@State private var showingEditSheet = false
@State private var showingDeleteAlert = false
@State private var showingShareSheet = false
@State private var shareFileURL: URL?
@State private var showingUpgradePrompt = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@@ -64,7 +63,10 @@ struct ContractorDetailView: View {
}
}
}
.sheet(isPresented: $showingShareSheet) {
.sheet(isPresented: Binding(
get: { shareFileURL != nil },
set: { if !$0 { shareFileURL = nil } }
)) {
if let url = shareFileURL {
ShareSheet(activityItems: [url])
}
@@ -101,9 +103,7 @@ struct ContractorDetailView: View {
private func deleteContractor() {
viewModel.deleteContractor(id: contractorId) { success in
if success {
Task { @MainActor in
// Small delay to allow state to settle before dismissing
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
dismiss()
}
}
@@ -113,7 +113,6 @@ struct ContractorDetailView: View {
private func shareContractor(_ contractor: Contractor) {
if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) {
shareFileURL = url
showingShareSheet = true
}
}

View File

@@ -276,7 +276,7 @@ struct ContractorFormSheet: View {
if viewModel.isCreating || viewModel.isUpdating {
ProgressView()
} else {
Text(contractor == nil ? L10n.Contractors.addButton : L10n.Common.save)
Text(contractor == nil ? L10n.Common.add : L10n.Common.save)
.bold()
}
}
@@ -297,6 +297,12 @@ struct ContractorFormSheet: View {
residenceViewModel.loadMyResidences()
loadContractorData()
}
.onChange(of: residenceViewModel.selectedResidence?.id) { _, _ in
if let residence = residenceViewModel.selectedResidence,
residence.id == selectedResidenceId {
selectedResidenceName = residence.name
}
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { saveContractor() }
@@ -440,11 +446,7 @@ struct ContractorFormSheet: View {
if let residenceId = contractor.residenceId {
selectedResidenceId = residenceId.int32Value
if let selectedResidenceId {
ComposeApp.ResidenceViewModel().getResidence(id: selectedResidenceId, onResult: { result in
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidenceName = success.data?.name
}
})
residenceViewModel.getResidence(id: selectedResidenceId)
}
}

View File

@@ -19,6 +19,9 @@ class ContractorViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
/// Guards against redundant detail reloads immediately after a mutation that already
/// set selectedContractor from its response.
private var suppressNextDetailReload = false
// MARK: - Initialization
@@ -28,6 +31,20 @@ class ContractorViewModel: ObservableObject {
.receive(on: DispatchQueue.main)
.sink { [weak self] contractors in
self?.contractors = contractors
// Auto-refresh selectedContractor when the list changes,
// so detail views stay current after mutations from other ViewModels.
// ContractorSummary and Contractor are different types, so we can't
// copy fields directly. Instead, if selectedContractor exists and a
// matching summary is found, reload the full detail from the API.
if let self = self,
let currentId = self.selectedContractor?.id,
contractors.contains(where: { $0.id == currentId }) {
if self.suppressNextDetailReload {
self.suppressNextDetailReload = false
} else {
self.reloadSelectedContractorQuietly(id: currentId)
}
}
}
.store(in: &cancellables)
}
@@ -99,10 +116,12 @@ class ContractorViewModel: ObservableObject {
do {
let result = try await APILayer.shared.createContractor(request: request)
if result is ApiResultSuccess<Contractor> {
if let success = result as? ApiResultSuccess<Contractor> {
self.successMessage = "Contractor added successfully"
self.isCreating = false
// DataManager is updated by APILayer, view updates via observation
// Update selectedContractor with the newly created contractor
self.suppressNextDetailReload = true
self.selectedContractor = success.data
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
@@ -129,10 +148,12 @@ class ContractorViewModel: ObservableObject {
do {
let result = try await APILayer.shared.updateContractor(id: id, request: request)
if result is ApiResultSuccess<Contractor> {
if let success = result as? ApiResultSuccess<Contractor> {
self.successMessage = "Contractor updated successfully"
self.isUpdating = false
// DataManager is updated by APILayer, view updates via observation
// Update selectedContractor immediately so detail views stay fresh
self.suppressNextDetailReload = true
self.selectedContractor = success.data
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
@@ -186,8 +207,10 @@ class ContractorViewModel: ObservableObject {
do {
let result = try await APILayer.shared.toggleFavorite(id: id)
if result is ApiResultSuccess<Contractor> {
// DataManager is updated by APILayer, view updates via observation
if let success = result as? ApiResultSuccess<Contractor> {
// Update selectedContractor immediately so detail views stay fresh
self.suppressNextDetailReload = true
self.selectedContractor = success.data
completion(true)
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
@@ -207,4 +230,21 @@ class ContractorViewModel: ObservableObject {
errorMessage = nil
successMessage = nil
}
// MARK: - Private Helpers
/// Silently reload the selected contractor detail without showing loading state.
/// Used when the contractors list updates and we need to keep selectedContractor fresh.
private func reloadSelectedContractorQuietly(id: Int32) {
Task {
do {
let result = try await APILayer.shared.getContractor(id: id, forceRefresh: true)
if let success = result as? ApiResultSuccess<Contractor> {
self.selectedContractor = success.data
}
} catch {
// Silently ignore this is a background refresh, not user-initiated
}
}
}
}

View File

@@ -96,7 +96,10 @@ struct ContractorsListView: View {
}
},
onRefresh: {
loadContractors(forceRefresh: true)
viewModel.loadContractors(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
},
onRetry: {
loadContractors()

View File

@@ -199,7 +199,7 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
let errorMessage: String?
let content: ([T]) -> Content
let emptyContent: () -> EmptyContent
let onRefresh: () -> Void
let onRefresh: () async -> Void
let onRetry: () -> Void
init(
@@ -208,7 +208,7 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
errorMessage: String?,
@ViewBuilder content: @escaping ([T]) -> Content,
@ViewBuilder emptyContent: @escaping () -> EmptyContent,
onRefresh: @escaping () -> Void,
onRefresh: @escaping () async -> Void,
onRetry: @escaping () -> Void
) {
self.items = items
@@ -248,10 +248,7 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
}
}
.refreshable {
await withCheckedContinuation { continuation in
onRefresh()
continuation.resume()
}
await onRefresh()
}
}
}

View File

@@ -1,107 +0,0 @@
import Foundation
import SwiftUI
import ComposeApp
// MARK: - Contractor Form State
/// Form state container for creating/editing a contractor
struct ContractorFormState: FormState {
var name = FormField<String>()
var company = FormField<String>()
var phone = FormField<String>()
var email = FormField<String>()
var website = FormField<String>()
var streetAddress = FormField<String>()
var city = FormField<String>()
var stateProvince = FormField<String>()
var postalCode = FormField<String>()
var notes = FormField<String>()
var isFavorite: Bool = false
// Residence selection (optional - nil means personal contractor)
var selectedResidenceId: Int32?
var selectedResidenceName: String?
// Specialty IDs (multiple selection)
var selectedSpecialtyIds: [Int32] = []
// For edit mode
var existingContractorId: Int32?
var isEditMode: Bool {
existingContractorId != nil
}
var isValid: Bool {
!name.isEmpty
}
mutating func validateAll() {
name.validate { ValidationRules.validateRequired($0, fieldName: "Name") }
// Optional email validation
if !email.isEmpty {
email.validate { value in
ValidationRules.isValidEmail(value) ? nil : .invalidEmail
}
}
}
mutating func reset() {
name = FormField<String>()
company = FormField<String>()
phone = FormField<String>()
email = FormField<String>()
website = FormField<String>()
streetAddress = FormField<String>()
city = FormField<String>()
stateProvince = FormField<String>()
postalCode = FormField<String>()
notes = FormField<String>()
isFavorite = false
selectedResidenceId = nil
selectedResidenceName = nil
selectedSpecialtyIds = []
existingContractorId = nil
}
/// Create ContractorCreateRequest from form state
func toCreateRequest() -> ContractorCreateRequest {
ContractorCreateRequest(
name: name.trimmedValue,
residenceId: selectedResidenceId.map { KotlinInt(int: $0) },
company: company.isEmpty ? nil : company.trimmedValue,
phone: phone.isEmpty ? nil : phone.trimmedValue,
email: email.isEmpty ? nil : email.trimmedValue,
website: website.isEmpty ? nil : website.trimmedValue,
streetAddress: streetAddress.isEmpty ? nil : streetAddress.trimmedValue,
city: city.isEmpty ? nil : city.trimmedValue,
stateProvince: stateProvince.isEmpty ? nil : stateProvince.trimmedValue,
postalCode: postalCode.isEmpty ? nil : postalCode.trimmedValue,
rating: nil,
isFavorite: isFavorite,
notes: notes.isEmpty ? nil : notes.trimmedValue,
specialtyIds: selectedSpecialtyIds.isEmpty ? nil : selectedSpecialtyIds.map { KotlinInt(int: $0) }
)
}
/// Create ContractorUpdateRequest from form state
func toUpdateRequest() -> ContractorUpdateRequest {
ContractorUpdateRequest(
name: name.isEmpty ? nil : name.trimmedValue,
residenceId: selectedResidenceId.map { KotlinInt(int: $0) },
company: company.isEmpty ? nil : company.trimmedValue,
phone: phone.isEmpty ? nil : phone.trimmedValue,
email: email.isEmpty ? nil : email.trimmedValue,
website: website.isEmpty ? nil : website.trimmedValue,
streetAddress: streetAddress.isEmpty ? nil : streetAddress.trimmedValue,
city: city.isEmpty ? nil : city.trimmedValue,
stateProvince: stateProvince.isEmpty ? nil : stateProvince.trimmedValue,
postalCode: postalCode.isEmpty ? nil : postalCode.trimmedValue,
rating: nil,
isFavorite: isFavorite.asKotlin,
notes: notes.isEmpty ? nil : notes.trimmedValue,
specialtyIds: selectedSpecialtyIds.isEmpty ? nil : selectedSpecialtyIds.map { KotlinInt(int: $0) }
)
}
}

View File

@@ -96,30 +96,28 @@ class DataManagerObservable: ObservableObject {
// Authentication - authToken
let authTokenTask = Task { [weak self] in
for await token in DataManager.shared.authToken {
await MainActor.run {
guard let self else { return }
let previousToken = self.authToken
let wasAuthenticated = previousToken != nil
self.authToken = token
self.isAuthenticated = token != nil
guard let self else { return }
let previousToken = self.authToken
let wasAuthenticated = previousToken != nil
self.authToken = token
self.isAuthenticated = token != nil
// Token rotated/account switched without explicit logout.
if let previousToken, let token, previousToken != token {
PushNotificationManager.shared.clearRegistrationCache()
}
// Token rotated/account switched without explicit logout.
if let previousToken, let token, previousToken != token {
PushNotificationManager.shared.clearRegistrationCache()
}
// Keep widget auth in sync with token lifecycle.
if let token {
WidgetDataManager.shared.saveAuthToken(token)
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
}
// Keep widget auth in sync with token lifecycle.
if let token {
WidgetDataManager.shared.saveAuthToken(token)
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
}
// Clear widget cache on logout
if token == nil && wasAuthenticated {
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
PushNotificationManager.shared.clearRegistrationCache()
}
// Clear widget cache on logout
if token == nil && wasAuthenticated {
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
PushNotificationManager.shared.clearRegistrationCache()
}
}
}
@@ -128,10 +126,8 @@ class DataManagerObservable: ObservableObject {
// Authentication - currentUser
let currentUserTask = Task { [weak self] in
for await user in DataManager.shared.currentUser {
await MainActor.run {
guard let self else { return }
self.currentUser = user
}
guard let self else { return }
self.currentUser = user
}
}
observationTasks.append(currentUserTask)
@@ -139,10 +135,8 @@ class DataManagerObservable: ObservableObject {
// Theme
let themeIdTask = Task { [weak self] in
for await id in DataManager.shared.themeId {
await MainActor.run {
guard let self else { return }
self.themeId = id
}
guard let self else { return }
self.themeId = id
}
}
observationTasks.append(themeIdTask)
@@ -150,10 +144,8 @@ class DataManagerObservable: ObservableObject {
// Residences
let residencesTask = Task { [weak self] in
for await list in DataManager.shared.residences {
await MainActor.run {
guard let self else { return }
self.residences = list
}
guard let self else { return }
self.residences = list
}
}
observationTasks.append(residencesTask)
@@ -161,10 +153,8 @@ class DataManagerObservable: ObservableObject {
// MyResidences
let myResidencesTask = Task { [weak self] in
for await response in DataManager.shared.myResidences {
await MainActor.run {
guard let self else { return }
self.myResidences = response
}
guard let self else { return }
self.myResidences = response
}
}
observationTasks.append(myResidencesTask)
@@ -172,10 +162,8 @@ class DataManagerObservable: ObservableObject {
// TotalSummary
let totalSummaryTask = Task { [weak self] in
for await summary in DataManager.shared.totalSummary {
await MainActor.run {
guard let self else { return }
self.totalSummary = summary
}
guard let self else { return }
self.totalSummary = summary
}
}
observationTasks.append(totalSummaryTask)
@@ -183,10 +171,8 @@ class DataManagerObservable: ObservableObject {
// ResidenceSummaries
let residenceSummariesTask = Task { [weak self] in
for await summaries in DataManager.shared.residenceSummaries {
await MainActor.run {
guard let self else { return }
self.residenceSummaries = self.convertIntMap(summaries)
}
guard let self else { return }
self.residenceSummaries = self.convertIntMap(summaries)
}
}
observationTasks.append(residenceSummariesTask)
@@ -194,13 +180,12 @@ class DataManagerObservable: ObservableObject {
// AllTasks
let allTasksTask = Task { [weak self] in
for await tasks in DataManager.shared.allTasks {
await MainActor.run {
guard let self else { return }
self.allTasks = tasks
// Save to widget shared container (debounced)
if let tasks = tasks {
self.debouncedWidgetSave(tasks: tasks)
}
guard let self else { return }
self.allTasks = tasks
self.recomputeActiveTasks()
// Save to widget shared container (debounced)
if let tasks = tasks {
self.debouncedWidgetSave(tasks: tasks)
}
}
}
@@ -209,10 +194,8 @@ class DataManagerObservable: ObservableObject {
// TasksByResidence
let tasksByResidenceTask = Task { [weak self] in
for await tasks in DataManager.shared.tasksByResidence {
await MainActor.run {
guard let self else { return }
self.tasksByResidence = self.convertIntMap(tasks)
}
guard let self else { return }
self.tasksByResidence = self.convertIntMap(tasks)
}
}
observationTasks.append(tasksByResidenceTask)
@@ -220,10 +203,8 @@ class DataManagerObservable: ObservableObject {
// Documents
let documentsTask = Task { [weak self] in
for await docs in DataManager.shared.documents {
await MainActor.run {
guard let self else { return }
self.documents = docs
}
guard let self else { return }
self.documents = docs
}
}
observationTasks.append(documentsTask)
@@ -231,10 +212,8 @@ class DataManagerObservable: ObservableObject {
// DocumentsByResidence
let documentsByResidenceTask = Task { [weak self] in
for await docs in DataManager.shared.documentsByResidence {
await MainActor.run {
guard let self else { return }
self.documentsByResidence = self.convertIntArrayMap(docs)
}
guard let self else { return }
self.documentsByResidence = self.convertIntArrayMap(docs)
}
}
observationTasks.append(documentsByResidenceTask)
@@ -242,10 +221,8 @@ class DataManagerObservable: ObservableObject {
// Contractors
let contractorsTask = Task { [weak self] in
for await list in DataManager.shared.contractors {
await MainActor.run {
guard let self else { return }
self.contractors = list
}
guard let self else { return }
self.contractors = list
}
}
observationTasks.append(contractorsTask)
@@ -253,10 +230,8 @@ class DataManagerObservable: ObservableObject {
// Subscription
let subscriptionTask = Task { [weak self] in
for await sub in DataManager.shared.subscription {
await MainActor.run {
guard let self else { return }
self.subscription = sub
}
guard let self else { return }
self.subscription = sub
}
}
observationTasks.append(subscriptionTask)
@@ -264,10 +239,8 @@ class DataManagerObservable: ObservableObject {
// UpgradeTriggers
let upgradeTriggersTask = Task { [weak self] in
for await triggers in DataManager.shared.upgradeTriggers {
await MainActor.run {
guard let self else { return }
self.upgradeTriggers = self.convertStringMap(triggers)
}
guard let self else { return }
self.upgradeTriggers = self.convertStringMap(triggers)
}
}
observationTasks.append(upgradeTriggersTask)
@@ -275,10 +248,8 @@ class DataManagerObservable: ObservableObject {
// FeatureBenefits
let featureBenefitsTask = Task { [weak self] in
for await benefits in DataManager.shared.featureBenefits {
await MainActor.run {
guard let self else { return }
self.featureBenefits = benefits
}
guard let self else { return }
self.featureBenefits = benefits
}
}
observationTasks.append(featureBenefitsTask)
@@ -286,10 +257,8 @@ class DataManagerObservable: ObservableObject {
// Promotions
let promotionsTask = Task { [weak self] in
for await promos in DataManager.shared.promotions {
await MainActor.run {
guard let self else { return }
self.promotions = promos
}
guard let self else { return }
self.promotions = promos
}
}
observationTasks.append(promotionsTask)
@@ -297,10 +266,8 @@ class DataManagerObservable: ObservableObject {
// Lookups - ResidenceTypes
let residenceTypesTask = Task { [weak self] in
for await types in DataManager.shared.residenceTypes {
await MainActor.run {
guard let self else { return }
self.residenceTypes = types
}
guard let self else { return }
self.residenceTypes = types
}
}
observationTasks.append(residenceTypesTask)
@@ -308,10 +275,8 @@ class DataManagerObservable: ObservableObject {
// Lookups - TaskFrequencies
let taskFrequenciesTask = Task { [weak self] in
for await items in DataManager.shared.taskFrequencies {
await MainActor.run {
guard let self else { return }
self.taskFrequencies = items
}
guard let self else { return }
self.taskFrequencies = items
}
}
observationTasks.append(taskFrequenciesTask)
@@ -319,10 +284,8 @@ class DataManagerObservable: ObservableObject {
// Lookups - TaskPriorities
let taskPrioritiesTask = Task { [weak self] in
for await items in DataManager.shared.taskPriorities {
await MainActor.run {
guard let self else { return }
self.taskPriorities = items
}
guard let self else { return }
self.taskPriorities = items
}
}
observationTasks.append(taskPrioritiesTask)
@@ -330,10 +293,8 @@ class DataManagerObservable: ObservableObject {
// Lookups - TaskCategories
let taskCategoriesTask = Task { [weak self] in
for await items in DataManager.shared.taskCategories {
await MainActor.run {
guard let self else { return }
self.taskCategories = items
}
guard let self else { return }
self.taskCategories = items
}
}
observationTasks.append(taskCategoriesTask)
@@ -341,10 +302,8 @@ class DataManagerObservable: ObservableObject {
// Lookups - ContractorSpecialties
let contractorSpecialtiesTask = Task { [weak self] in
for await items in DataManager.shared.contractorSpecialties {
await MainActor.run {
guard let self else { return }
self.contractorSpecialties = items
}
guard let self else { return }
self.contractorSpecialties = items
}
}
observationTasks.append(contractorSpecialtiesTask)
@@ -352,10 +311,8 @@ class DataManagerObservable: ObservableObject {
// Task Templates
let taskTemplatesTask = Task { [weak self] in
for await items in DataManager.shared.taskTemplates {
await MainActor.run {
guard let self else { return }
self.taskTemplates = items
}
guard let self else { return }
self.taskTemplates = items
}
}
observationTasks.append(taskTemplatesTask)
@@ -363,10 +320,8 @@ class DataManagerObservable: ObservableObject {
// Task Templates Grouped
let taskTemplatesGroupedTask = Task { [weak self] in
for await response in DataManager.shared.taskTemplatesGrouped {
await MainActor.run {
guard let self else { return }
self.taskTemplatesGrouped = response
}
guard let self else { return }
self.taskTemplatesGrouped = response
}
}
observationTasks.append(taskTemplatesGroupedTask)
@@ -374,10 +329,8 @@ class DataManagerObservable: ObservableObject {
// Metadata - isInitialized
let isInitializedTask = Task { [weak self] in
for await initialized in DataManager.shared.isInitialized {
await MainActor.run {
guard let self else { return }
self.isInitialized = initialized.boolValue
}
guard let self else { return }
self.isInitialized = initialized.boolValue
}
}
observationTasks.append(isInitializedTask)
@@ -385,10 +338,8 @@ class DataManagerObservable: ObservableObject {
// Metadata - lookupsInitialized
let lookupsInitializedTask = Task { [weak self] in
for await initialized in DataManager.shared.lookupsInitialized {
await MainActor.run {
guard let self else { return }
self.lookupsInitialized = initialized.boolValue
}
guard let self else { return }
self.lookupsInitialized = initialized.boolValue
}
}
observationTasks.append(lookupsInitializedTask)
@@ -396,10 +347,8 @@ class DataManagerObservable: ObservableObject {
// Metadata - lastSyncTime
let lastSyncTimeTask = Task { [weak self] in
for await time in DataManager.shared.lastSyncTime {
await MainActor.run {
guard let self else { return }
self.lastSyncTime = time.int64Value
}
guard let self else { return }
self.lastSyncTime = time.int64Value
}
}
observationTasks.append(lastSyncTimeTask)
@@ -516,6 +465,9 @@ class DataManagerObservable: ObservableObject {
}
// MARK: - Convenience Lookup Methods
// Note: These use O(n) linear search which is acceptable for small lookup arrays
// (typically <20 items each). Dictionary-based lookups would add complexity
// for negligible performance gain at this scale.
/// Get residence type by ID
func getResidenceType(id: Int32?) -> ResidenceType? {
@@ -579,9 +531,17 @@ class DataManagerObservable: ObservableObject {
// MARK: - Task Stats (Single Source of Truth)
// Uses API column names + shared calculateMetrics function
/// Active tasks (excludes completed and cancelled)
var activeTasks: [TaskResponse] {
guard let response = allTasks else { return [] }
/// Active tasks (excludes completed and cancelled).
/// Computed once when `allTasks` changes and cached to avoid
/// redundant iteration in `totalTaskMetrics`, `taskMetrics(for:)`, etc.
private(set) var activeTasks: [TaskResponse] = []
/// Recompute cached activeTasks from allTasks
private func recomputeActiveTasks() {
guard let response = allTasks else {
activeTasks = []
return
}
var tasks: [TaskResponse] = []
for column in response.columns {
let columnName = column.name.lowercased()
@@ -590,7 +550,7 @@ class DataManagerObservable: ObservableObject {
}
tasks.append(contentsOf: column.tasks)
}
return tasks
activeTasks = tasks
}
/// Get tasks from a specific column by name

View File

@@ -7,18 +7,24 @@ import SwiftUI
extension Color {
// MARK: - Dynamic Theme Resolution
/// Shared App Group defaults for reading the active theme.
/// Thread-safe: UserDefaults is safe to read from any thread/actor.
private static let _themeDefaults: UserDefaults = {
UserDefaults(suiteName: "group.com.tt.casera.CaseraDev") ?? .standard
}()
private static func themed(_ name: String) -> Color {
// Both main app and widgets use the theme from ThemeManager
// Theme is shared via App Group UserDefaults
let theme = MainActor.assumeIsolated {
ThemeManager.shared.currentTheme.rawValue
}
// Read theme directly from shared UserDefaults instead of going through
// @MainActor-isolated ThemeManager.shared. This is safe to call from any
// actor context (including widget timeline providers and background threads).
let theme = _themeDefaults.string(forKey: "selectedTheme") ?? ThemeID.bright.rawValue
return Color("\(theme)/\(name)", bundle: nil)
}
// MARK: - Semantic Colors (Use These in UI)
// These dynamically resolve based on ThemeManager.shared.currentTheme
// Theme is shared between main app and widgets via App Group
// These dynamically resolve based on the active theme stored in App Group UserDefaults.
// Safe to call from any actor context (main app, widget extensions, background threads).
static var appPrimary: Color { themed("Primary") }
static var appSecondary: Color { themed("Secondary") }
static var appAccent: Color { themed("Accent") }

View File

@@ -40,6 +40,9 @@ struct DocumentsTabContent: View {
},
onRefresh: {
viewModel.loadDocuments(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
},
onRetry: {
viewModel.loadDocuments()

View File

@@ -42,6 +42,9 @@ struct WarrantiesTabContent: View {
},
onRefresh: {
viewModel.loadDocuments(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
},
onRetry: {
viewModel.loadDocuments()

View File

@@ -4,6 +4,10 @@ import ComposeApp
struct WarrantyCard: View {
let document: Document
var hasEndDate: Bool {
document.daysUntilExpiration != nil
}
var daysUntilExpiration: Int {
Int(document.daysUntilExpiration ?? 0)
}
@@ -90,7 +94,7 @@ struct WarrantyCard: View {
}
}
if document.isActive && daysUntilExpiration >= 0 {
if document.isActive && hasEndDate && daysUntilExpiration >= 0 {
Text(String(format: L10n.Documents.daysRemainingCount, daysUntilExpiration))
.font(.footnote.weight(.medium))
.foregroundColor(statusColor)

View File

@@ -14,7 +14,6 @@ struct DocumentDetailView: View {
@State private var downloadProgress: Double = 0
@State private var downloadError: String?
@State private var downloadedFileURL: URL?
@State private var showShareSheet = false
var body: some View {
ZStack {
@@ -43,6 +42,8 @@ struct DocumentDetailView: View {
.navigationDestination(isPresented: $navigateToEdit) {
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
EditDocumentView(document: successState.document)
} else {
Color.clear.onAppear { navigateToEdit = false }
}
}
.toolbar {
@@ -82,7 +83,7 @@ struct DocumentDetailView: View {
deleteSucceeded = true
}
}
.onChange(of: deleteSucceeded) { succeeded in
.onChange(of: deleteSucceeded) { _, succeeded in
if succeeded {
dismiss()
}
@@ -94,9 +95,14 @@ struct DocumentDetailView: View {
selectedIndex: $selectedImageIndex,
onDismiss: { showImageViewer = false }
)
} else {
Color.clear.onAppear { showImageViewer = false }
}
}
.sheet(isPresented: $showShareSheet) {
.sheet(isPresented: Binding(
get: { downloadedFileURL != nil },
set: { if !$0 { downloadedFileURL = nil } }
)) {
if let fileURL = downloadedFileURL {
ShareSheet(activityItems: [fileURL])
}
@@ -105,6 +111,9 @@ struct DocumentDetailView: View {
// MARK: - Download File
// FIX_SKIPPED: LE-4 downloadFile() is an 80-line method performing direct URLSession
// networking inside the view. Fixing requires extracting a dedicated DownloadViewModel
// or DocumentDownloadManager architectural refactor deferred.
private func downloadFile(document: Document) {
guard let fileUrl = document.fileUrl else {
downloadError = "No file URL available"
@@ -177,7 +186,6 @@ struct DocumentDetailView: View {
await MainActor.run {
downloadedFileURL = destinationURL
isDownloading = false
showShareSheet = true
}
} catch {

View File

@@ -216,7 +216,7 @@ struct DocumentFormView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button(isEditMode ? L10n.Documents.update : L10n.Common.save) {
Button(isEditMode ? L10n.Common.save : L10n.Common.add) {
submitForm()
}
.disabled(!canSave || isProcessing)
@@ -232,7 +232,7 @@ struct DocumentFormView: View {
}
))
}
.onChange(of: selectedPhotoItems) { items in
.onChange(of: selectedPhotoItems) { _, items in
Task {
selectedImages.removeAll()
for item in items {

View File

@@ -28,7 +28,7 @@ struct DocumentsWarrantiesView: View {
if showActiveOnly && doc.isActive != true {
return false
}
if let category = selectedCategory, doc.category != category {
if let category = selectedCategory, doc.category?.lowercased() != category.lowercased() {
return false
}
return true
@@ -38,17 +38,13 @@ struct DocumentsWarrantiesView: View {
var documents: [Document] {
documentViewModel.documents.filter { doc in
guard doc.documentType != "warranty" else { return false }
if let docType = selectedDocType, doc.documentType != docType {
if let docType = selectedDocType, doc.documentType.lowercased() != docType.lowercased() {
return false
}
return true
}
}
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents")
}
var body: some View {
ZStack {
WarmGradientBackground()
@@ -209,6 +205,8 @@ struct DocumentsWarrantiesView: View {
.navigationDestination(isPresented: $navigateToPushDocument) {
if let documentId = pushTargetDocumentId {
DocumentDetailView(documentId: documentId)
} else {
Color.clear.onAppear { navigateToPushDocument = false }
}
}
}
@@ -226,7 +224,13 @@ struct DocumentsWarrantiesView: View {
}
private func navigateToDocumentFromPush(documentId: Int) {
selectedTab = .warranties
// Look up the document to determine the correct tab
if let document = documentViewModel.documents.first(where: { $0.id?.int32Value == Int32(documentId) }) {
selectedTab = document.documentType == "warranty" ? .warranties : .documents
} else {
// Default to warranties if document not found in cache
selectedTab = .warranties
}
pushTargetDocumentId = Int32(documentId)
navigateToPushDocument = true
PushNotificationManager.shared.pendingNavigationDocumentId = nil

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") }

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)
}

View File

@@ -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)
}
}

View File

@@ -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 = []
}

View File

@@ -42,19 +42,6 @@
<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>

View File

@@ -107,10 +107,6 @@
},
"$" : {
},
"$%@" : {
"comment" : "A label displaying the cost of a task. The argument is the cost of the task.",
"isCommentAutoGenerated" : true
},
"000000" : {
"comment" : "A placeholder text for a 6-digit code field.",
@@ -316,8 +312,8 @@
"comment" : "An alert message displayed when the user taps the \"Archive\" button on a task. It confirms that the user intends to archive the task and provides a hint that the task can be restored later.",
"isCommentAutoGenerated" : true
},
"Are you sure you want to cancel this task? This action cannot be undone." : {
"comment" : "An alert message displayed when a user taps the \"Cancel Task\" button in the task details view. It confirms that the user intends to cancel the task and warns them that the action cannot be undone.",
"Are you sure you want to cancel this task? You can undo this later." : {
"comment" : "An alert message displayed when a user taps the \"Cancel Task\" button in a task list. It confirms that the user intends to cancel the task and provides a way to undo the action.",
"isCommentAutoGenerated" : true
},
"Are you sure you want to remove %@ from this residence?" : {
@@ -4298,6 +4294,71 @@
"comment" : "A description of how long the verification code is valid for.",
"isCommentAutoGenerated" : true
},
"common_add" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hinzufügen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Agregar"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajouter"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiungi"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "追加"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "추가"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Toevoegen"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "Adicionar"
}
},
"zh" : {
"stringUnit" : {
"state" : "translated",
"value" : "添加"
}
}
}
},
"common_back" : {
"extractionState" : "manual",
"localizations" : {

View File

@@ -117,7 +117,9 @@ extension AppleSignInManager: ASAuthorizationControllerDelegate {
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
MainActor.assumeIsolated {
// This method is always called on the main thread by Apple's framework.
// Use DispatchQueue.main.sync as a safe bridge instead of assumeIsolated.
DispatchQueue.main.sync {
// Get the key window for presentation
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first(where: { $0.isKeyWindow }) else {

View File

@@ -174,8 +174,24 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
return
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let idToken = json["id_token"] as? String else {
let json: [String: Any]
do {
guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
resetOAuthState()
isLoading = false
errorMessage = "Failed to get ID token from Google"
return
}
json = parsed
} catch {
print("GoogleSignInManager: Failed to parse token response JSON: \(error)")
resetOAuthState()
isLoading = false
errorMessage = "Failed to get ID token from Google"
return
}
guard let idToken = json["id_token"] as? String else {
resetOAuthState()
isLoading = false
errorMessage = "Failed to get ID token from Google"
@@ -194,12 +210,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
/// Send Google ID token to backend for verification and authentication
private func sendToBackend(idToken: String) async {
let request = GoogleSignInRequest(idToken: idToken)
let result = try? await APILayer.shared.googleSignIn(request: request)
guard let result else {
let result: Any
do {
result = try await APILayer.shared.googleSignIn(request: request)
} catch {
print("GoogleSignInManager: Backend sign-in request failed: \(error)")
resetOAuthState()
isLoading = false
errorMessage = "Sign in failed. Please try again."
errorMessage = "Sign in failed: \(error.localizedDescription)"
return
}

View File

@@ -1,59 +1,75 @@
import SwiftUI
struct MainTabView: View {
enum Tab: Hashable {
case residences
case tasks
case contractors
case documents
}
@EnvironmentObject private var themeManager: ThemeManager
@State private var selectedTab = 0
@State private var selectedTab: Tab = .residences
@State private var residencesPath = NavigationPath()
@State private var tasksPath = NavigationPath()
@State private var contractorsPath = NavigationPath()
@State private var documentsPath = NavigationPath()
@ObservedObject private var authManager = AuthenticationManager.shared
@ObservedObject private var pushManager = PushNotificationManager.shared
var refreshID: UUID
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
NavigationStack(path: $residencesPath) {
ResidencesListView()
}
.id(refreshID)
.tabItem {
Label("Residences", image: "tab_view_house")
}
.tag(0)
.tag(Tab.residences)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
NavigationStack {
NavigationStack(path: $tasksPath) {
AllTasksView()
}
.id(refreshID)
.tabItem {
Label("Tasks", systemImage: "checklist")
}
.tag(1)
.tag(Tab.tasks)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
NavigationStack {
NavigationStack(path: $contractorsPath) {
ContractorsListView()
}
.id(refreshID)
.tabItem {
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
}
.tag(2)
.tag(Tab.contractors)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
NavigationStack {
NavigationStack(path: $documentsPath) {
DocumentsWarrantiesView(residenceId: nil)
}
.id(refreshID)
.tabItem {
Label("Docs", systemImage: "doc.text.fill")
}
.tag(3)
.tag(Tab.documents)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
}
.tabViewStyle(.sidebarAdaptable)
.tint(Color.appPrimary)
.onChange(of: authManager.isAuthenticated) { _, _ in
selectedTab = 0
selectedTab = .residences
}
.onAppear {
// FIX_SKIPPED(F-10): UITabBar.appearance() is the standard SwiftUI pattern
// for customizing tab bar appearance. The global side effect persists but
// there is no safe alternative without UIKit hosting.
// Configure tab bar appearance
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
@@ -61,18 +77,18 @@ struct MainTabView: View {
// Use theme-aware colors
appearance.backgroundColor = UIColor(Color.appBackgroundSecondary)
// Selected item
// Selected item uses Dynamic Type caption2 style (A-2)
appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary)
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor(Color.appPrimary),
.font: UIFont.systemFont(ofSize: 10, weight: .semibold)
.font: UIFont.preferredFont(forTextStyle: .caption2)
]
// Normal item
// Normal item uses Dynamic Type caption2 style (A-2)
appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: UIColor(Color.appTextSecondary),
.font: UIFont.systemFont(ofSize: 10, weight: .medium)
.font: UIFont.preferredFont(forTextStyle: .caption2)
]
UITabBar.appearance().standardAppearance = appearance
@@ -80,27 +96,27 @@ struct MainTabView: View {
// Handle pending navigation from push notification
if pushManager.pendingNavigationTaskId != nil {
selectedTab = 1
selectedTab = .tasks
} else if pushManager.pendingNavigationDocumentId != nil {
selectedTab = 3
selectedTab = .documents
} else if pushManager.pendingNavigationResidenceId != nil {
selectedTab = 0
selectedTab = .residences
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
selectedTab = 1
selectedTab = .tasks
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
selectedTab = 1
selectedTab = .tasks
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { _ in
selectedTab = 0
selectedTab = .residences
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { _ in
selectedTab = 3
selectedTab = .documents
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
selectedTab = 0
selectedTab = .residences
}
}
}

View File

@@ -28,12 +28,10 @@ struct OnboardingCoordinator: View {
}
private func goBack(to step: OnboardingStep) {
isNavigatingBack = true
withAnimation(.easeInOut(duration: 0.3)) {
isNavigatingBack = true
onboardingState.currentStep = step
}
// Reset after animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
} completion: {
isNavigatingBack = false
}
}

View File

@@ -505,6 +505,7 @@ struct OnboardingFirstTaskContent: View {
// Format today's date as YYYY-MM-DD for the API
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd"
let todayString = dateFormatter.string(from: Date())

View File

@@ -68,12 +68,12 @@ class OnboardingState: ObservableObject {
pendingPostalCode = zip
isLoadingTemplates = true
Task {
defer { self.isLoadingTemplates = false }
let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip)
if let success = result as? ApiResultSuccess<NSArray>,
let templates = success.data as? [TaskTemplate] {
self.regionalTemplates = templates
}
self.isLoadingTemplates = false
}
}

View File

@@ -333,7 +333,12 @@ struct OnboardingSubscriptionContent: View {
await MainActor.run {
isLoading = false
if transaction != nil {
onSubscribe()
// Check if backend verification failed (purchase valid but pending server confirmation)
if let backendError = storeKit.purchaseError {
purchaseError = backendError
} else {
onSubscribe()
}
} else {
purchaseError = "Purchase was cancelled. You can continue with Free or try again."
}

View File

@@ -6,7 +6,6 @@ struct ForgotPasswordView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
ZStack {
WarmGradientBackground()
@@ -185,7 +184,6 @@ struct ForgotPasswordView: View {
.onAppear {
isEmailFocused = true
}
}
}
}

View File

@@ -11,19 +11,21 @@ struct PasswordResetFlow: View {
}
var body: some View {
Group {
switch viewModel.currentStep {
case .requestCode:
ForgotPasswordView(viewModel: viewModel)
case .verifyCode:
VerifyResetCodeView(viewModel: viewModel)
case .resetPassword, .loggingIn, .success:
ResetPasswordView(viewModel: viewModel, onSuccess: {
dismiss()
})
NavigationStack {
Group {
switch viewModel.currentStep {
case .requestCode:
ForgotPasswordView(viewModel: viewModel)
case .verifyCode:
VerifyResetCodeView(viewModel: viewModel)
case .resetPassword, .loggingIn, .success:
ResetPasswordView(viewModel: viewModel, onSuccess: {
dismiss()
})
}
}
.animation(.easeInOut, value: viewModel.currentStep)
}
.animation(.easeInOut, value: viewModel.currentStep)
.onAppear {
// Set up callback for auto-login success
// Capture dismiss and onLoginSuccess directly to avoid holding the entire view struct

View File

@@ -35,7 +35,6 @@ struct ResetPasswordView: View {
}
var body: some View {
NavigationStack {
ZStack {
WarmGradientBackground()
@@ -326,7 +325,6 @@ struct ResetPasswordView: View {
.onAppear {
focusedField = .newPassword
}
}
}
}

View File

@@ -6,7 +6,6 @@ struct VerifyResetCodeView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
ZStack {
WarmGradientBackground()
@@ -229,7 +228,6 @@ struct VerifyResetCodeView: View {
.onAppear {
isCodeFocused = true
}
}
}
}

View File

@@ -11,7 +11,6 @@ class ProfileViewModel: ObservableObject {
@Published var firstName: String = ""
@Published var lastName: String = ""
@Published var email: String = ""
@Published var isEditing: Bool = false
@Published var isLoading: Bool = false
@Published var isLoadingUser: Bool = true
@Published var errorMessage: String?
@@ -29,7 +28,7 @@ class ProfileViewModel: ObservableObject {
DataManagerObservable.shared.$currentUser
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
guard let self, !self.isEditing else { return }
guard let self else { return }
if let user = user {
self.firstName = user.firstName ?? ""
self.lastName = user.lastName ?? ""

View File

@@ -6,6 +6,10 @@ import ComposeApp
@MainActor
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
/// Throttle subscription refreshes to at most once every 5 minutes
private static var lastSubscriptionRefresh: Date?
private static let subscriptionRefreshInterval: TimeInterval = 300 // 5 minutes
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -45,10 +49,17 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
// Clear badge when app becomes active
PushNotificationManager.shared.clearBadge()
// Refresh StoreKit subscription status when app comes to foreground
// Refresh StoreKit subscription status when app comes to foreground (throttled to every 5 min)
// This ensures we have the latest subscription state if it changed while app was in background
Task {
await StoreKitManager.shared.refreshSubscriptionStatus()
let now = Date()
if let lastRefresh = Self.lastSubscriptionRefresh,
now.timeIntervalSince(lastRefresh) < Self.subscriptionRefreshInterval {
// Skip refreshed recently
} else {
Self.lastSubscriptionRefresh = now
Task {
await StoreKitManager.shared.refreshSubscriptionStatus()
}
}
}

View File

@@ -77,7 +77,7 @@ struct JoinResidenceView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.onChange(of: shareCode) { newValue in
.onChange(of: shareCode) { _, newValue in
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}

View File

@@ -1,6 +1,9 @@
import SwiftUI
import ComposeApp
// FIX_SKIPPED: LE-2 This view calls APILayer directly (loadUsers, loadShareCode,
// generateShareCode, removeUser). Fixing requires extracting a dedicated ManageUsersViewModel.
// Architectural refactor deferred requires new ViewModel.
struct ManageUsersView: View {
let residenceId: Int32
let residenceName: String
@@ -36,7 +39,6 @@ struct ManageUsersView: View {
if isPrimaryOwner {
ShareCodeCard(
shareCode: shareCode,
residenceName: residenceName,
isGeneratingCode: isGeneratingCode,
isGeneratingPackage: sharingManager.isGeneratingPackage,
onGenerateCode: generateShareCode,
@@ -81,9 +83,9 @@ struct ManageUsersView: View {
}
}
.onAppear {
// Clear share code on appear so it's always blank
shareCode = nil
loadUsers()
loadShareCode()
}
.sheet(isPresented: Binding(
get: { shareFileURL != nil },

View File

@@ -19,7 +19,6 @@ struct ResidenceDetailView: View {
@State private var showAddTask = false
@State private var showEditResidence = false
@State private var showEditTask = false
@State private var showManageUsers = false
@State private var selectedTaskForEdit: TaskResponse?
@State private var selectedTaskForComplete: TaskResponse?
@@ -107,10 +106,13 @@ struct ResidenceDetailView: View {
EditResidenceView(residence: residence, isPresented: $showEditResidence)
}
}
.sheet(isPresented: $showEditTask) {
if let task = selectedTaskForEdit {
EditTaskView(task: task, isPresented: $showEditTask)
}
.sheet(item: $selectedTaskForEdit, onDismiss: {
loadResidenceTasks(forceRefresh: true)
}) { task in
EditTaskView(task: task, isPresented: Binding(
get: { selectedTaskForEdit != nil },
set: { if !$0 { selectedTaskForEdit = nil } }
))
}
.sheet(item: $selectedTaskForComplete, onDismiss: {
if let task = pendingCompletedTask {
@@ -176,31 +178,26 @@ struct ResidenceDetailView: View {
}
// MARK: onChange & lifecycle
.onChange(of: viewModel.reportMessage) { message in
.onChange(of: viewModel.reportMessage) { _, message in
if message != nil {
showReportAlert = true
}
}
.onChange(of: viewModel.selectedResidence) { residence in
.onChange(of: viewModel.selectedResidence) { _, residence in
if residence != nil {
hasAppeared = true
}
}
.onChange(of: showAddTask) { isShowing in
.onChange(of: showAddTask) { _, isShowing in
if !isShowing {
loadResidenceTasks(forceRefresh: true)
}
}
.onChange(of: showEditResidence) { isShowing in
.onChange(of: showEditResidence) { _, isShowing in
if !isShowing {
loadResidenceData()
}
}
.onChange(of: showEditTask) { isShowing in
if !isShowing {
loadResidenceTasks(forceRefresh: true)
}
}
.onAppear {
loadResidenceData()
}
@@ -252,7 +249,6 @@ private extension ResidenceDetailView {
tasksResponse: tasksResponse,
taskViewModel: taskViewModel,
selectedTaskForEdit: $selectedTaskForEdit,
showEditTask: $showEditTask,
selectedTaskForComplete: $selectedTaskForComplete,
selectedTaskForArchive: $selectedTaskForArchive,
showArchiveConfirmation: $showArchiveConfirmation,
@@ -335,17 +331,6 @@ private extension ResidenceDetailView {
}
}
// MARK: - Organic Card Button Style
private struct OrganicCardButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Toolbars
private extension ResidenceDetailView {
@@ -466,20 +451,23 @@ private extension ResidenceDetailView {
}
}
// FIX_SKIPPED: LE-3 deleteResidence() calls APILayer.shared.deleteResidence() directly
// from the view. ResidenceViewModel does not expose a delete method. Fixing requires adding
// deleteResidence() to the shared ViewModel layer architectural refactor deferred.
func deleteResidence() {
guard TokenStorage.shared.getToken() != nil else { return }
isDeleting = true
Task {
do {
let result = try await APILayer.shared.deleteResidence(
id: Int32(Int(residenceId))
)
await MainActor.run {
self.isDeleting = false
if result is ApiResultSuccess<KotlinUnit> {
dismiss()
} else if let errorResult = ApiResultBridge.error(from: result) {
@@ -537,7 +525,6 @@ private struct TasksSectionContainer: View {
@ObservedObject var taskViewModel: TaskViewModel
@Binding var selectedTaskForEdit: TaskResponse?
@Binding var showEditTask: Bool
@Binding var selectedTaskForComplete: TaskResponse?
@Binding var selectedTaskForArchive: TaskResponse?
@Binding var showArchiveConfirmation: Bool
@@ -556,7 +543,6 @@ private struct TasksSectionContainer: View {
tasksResponse: tasksResponse,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: { task in
selectedTaskForCancel = task

View File

@@ -288,11 +288,6 @@ class ResidenceViewModel: ObservableObject {
errorMessage = nil
}
func loadResidenceContractors(residenceId: Int32) {
// This can now be handled directly via APILayer if needed
// or through DataManagerObservable.shared.contractors
}
func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
isLoading = true
errorMessage = nil

View File

@@ -9,10 +9,8 @@ struct ResidencesListView: View {
@State private var showingUpgradePrompt = false
@State private var showingSettings = false
@State private var pushTargetResidenceId: Int32?
@State private var showLoginCover = false
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ZStack {
@@ -32,6 +30,9 @@ struct ResidencesListView: View {
},
onRefresh: {
viewModel.loadMyResidences(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
},
onRetry: {
viewModel.loadMyResidences()
@@ -113,36 +114,19 @@ struct ResidencesListView: View {
viewModel.loadMyResidences()
// Also load tasks to populate summary stats
taskViewModel.loadTasks()
} else {
showLoginCover = true
}
}
.onChange(of: scenePhase) { newPhase in
// Refresh data when app comes back from background
if newPhase == .active && authManager.isAuthenticated {
viewModel.loadMyResidences(forceRefresh: true)
taskViewModel.loadTasks(forceRefresh: true)
}
}
.fullScreenCover(isPresented: $showLoginCover) {
LoginView(onLoginSuccess: {
authManager.isAuthenticated = true
showLoginCover = false
viewModel.loadMyResidences()
taskViewModel.loadTasks()
})
.interactiveDismissDisabled()
}
.onChange(of: authManager.isAuthenticated) { isAuth in
// P-5: Removed redundant .onChange(of: scenePhase) handler.
// iOSApp.swift already handles foreground refresh globally, so per-view
// scenePhase handlers fire duplicate network requests.
.onChange(of: authManager.isAuthenticated) { _, isAuth in
if isAuth {
// User just logged in or registered - load their residences and tasks
showLoginCover = false
viewModel.loadMyResidences()
taskViewModel.loadTasks()
} else {
// User logged out - clear data and show login
// User logged out - clear data
viewModel.myResidences = nil
showLoginCover = true
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in
@@ -150,13 +134,8 @@ struct ResidencesListView: View {
navigateToResidenceFromPush(residenceId: residenceId)
}
}
.navigationDestination(isPresented: Binding(
get: { pushTargetResidenceId != nil },
set: { if !$0 { pushTargetResidenceId = nil } }
)) {
if let residenceId = pushTargetResidenceId {
ResidenceDetailView(residenceId: residenceId)
}
.navigationDestination(item: $pushTargetResidenceId) { residenceId in
ResidenceDetailView(residenceId: residenceId)
}
}
@@ -253,17 +232,6 @@ private struct ResidencesContent: View {
}
}
// MARK: - Organic Card Button Style
private struct OrganicCardButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Organic Empty Residences View
private struct OrganicEmptyResidencesView: View {

View File

@@ -253,7 +253,7 @@ struct ResidenceFormView: View {
if viewModel.isLoading {
ProgressView()
} else {
Text(L10n.Common.save)
Text(isEditMode ? L10n.Common.save : L10n.Common.add)
}
}
.disabled(!canSave || viewModel.isLoading)

View File

@@ -190,7 +190,7 @@ struct RootView: View {
// Show main app
ZStack(alignment: .topLeading) {
MainTabView(refreshID: refreshID)
.onChange(of: themeManager.currentTheme) { _ in
.onChange(of: themeManager.currentTheme) { _, _ in
refreshID = UUID()
}
Color.clear

View File

@@ -0,0 +1,20 @@
import SwiftUI
// MARK: - Task Category Colors
extension Color {
/// Returns the semantic color for a given task category name.
/// Shared across all views that display category-colored elements.
static func taskCategoryColor(for categoryName: String) -> Color {
switch categoryName.lowercased() {
case "plumbing": return Color.appSecondary
case "safety", "electrical": return Color.appError
case "hvac": return Color.appPrimary
case "appliances": return Color.appAccent
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
case "interior": return Color(hex: "#AF52DE") ?? .purple
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
default: return Color.appPrimary
}
}
}

View File

@@ -4,7 +4,7 @@ import Foundation
extension Date {
/// Formats date as "MMM d, yyyy" (e.g., "Jan 15, 2024")
func formatted() -> String {
func formattedMedium() -> String {
DateFormatters.shared.mediumDate.string(from: self)
}
@@ -75,7 +75,7 @@ extension String {
/// Converts API date string to formatted display string (e.g., "Jan 2, 2025")
func toFormattedDate() -> String {
guard let date = self.toDate() else { return self }
return date.formatted()
return date.formattedMedium()
}
/// Checks if date string represents an overdue date

View File

@@ -15,29 +15,52 @@ extension KotlinDouble {
}
}
// MARK: - Cached NumberFormatters
/// Static cached NumberFormatter instances to avoid per-call allocation overhead.
/// These formatters are mutated per-call for variable parameters (fractionDigits, currencyCode)
/// and are safe because all callers run on the main thread (@MainActor ViewModels / SwiftUI views).
private enum CachedFormatters {
static let currency: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .currency
f.currencyCode = "USD"
return f
}()
static let decimal: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
return f
}()
static let percent: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .percent
return f
}()
}
// MARK: - Double Extensions for Currency and Number Formatting
extension Double {
/// Formats as currency (e.g., "$1,234.56")
func toCurrency() -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
let formatter = CachedFormatters.currency
formatter.currencyCode = "USD"
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
}
/// Formats as currency with currency symbol (e.g., "$1,234.56")
func toCurrencyString(currencyCode: String = "USD") -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
let formatter = CachedFormatters.currency
formatter.currencyCode = currencyCode
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
}
/// Formats with comma separators (e.g., "1,234.56")
func toDecimalString(fractionDigits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
let formatter = CachedFormatters.decimal
formatter.minimumFractionDigits = fractionDigits
formatter.maximumFractionDigits = fractionDigits
return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self)
@@ -45,8 +68,7 @@ extension Double {
/// Formats as percentage (e.g., "45.5%")
func toPercentage(fractionDigits: Int = 1) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .percent
let formatter = CachedFormatters.percent
formatter.minimumFractionDigits = fractionDigits
formatter.maximumFractionDigits = fractionDigits
return formatter.string(from: NSNumber(value: self / 100)) ?? "\(self)%"
@@ -78,8 +100,9 @@ extension Double {
extension Int {
/// Formats with comma separators (e.g., "1,234")
func toFormattedString() -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
let formatter = CachedFormatters.decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 0
return formatter.string(from: NSNumber(value: self)) ?? "\(self)"
}

View File

@@ -36,7 +36,7 @@ extension String {
/// Validates phone number (basic check)
var isValidPhone: Bool {
let phoneRegex = "^[0-9+\\-\\(\\)\\s]{10,}$"
let phoneRegex = "^(?=.*[0-9])[0-9+\\-\\(\\)\\s]{10,}$"
let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex)
return phonePredicate.evaluate(with: self)
}

View File

@@ -135,6 +135,17 @@ extension View {
}
}
// MARK: - Organic Card Button Style
struct OrganicCardButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Metadata Pill Styles
struct MetadataPillStyle: ViewModifier {

View File

@@ -6,11 +6,8 @@ struct FeatureComparisonView: View {
@Binding var isPresented: Bool
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
@State private var showUpgradePrompt = false
@State private var selectedProduct: Product?
@State private var isProcessing = false
@State private var errorMessage: String?
@State private var showSuccessAlert = false
/// Whether the user is already subscribed from a non-iOS platform
private var isSubscribedOnOtherPlatform: Bool {
@@ -124,11 +121,10 @@ struct FeatureComparisonView: View {
ForEach(storeKit.products, id: \.id) { product in
SubscriptionButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
isSelected: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: purchaseHelper.isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
purchaseHelper.handlePurchase(product)
}
)
}
@@ -151,7 +147,7 @@ struct FeatureComparisonView: View {
}
// Error Message
if let error = errorMessage {
if let error = purchaseHelper.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
@@ -168,7 +164,7 @@ struct FeatureComparisonView: View {
// Restore Purchases
if !isSubscribedOnOtherPlatform {
Button(action: {
handleRestore()
purchaseHelper.handleRestore()
}) {
Text("Restore Purchases")
.font(.caption)
@@ -187,7 +183,7 @@ struct FeatureComparisonView: View {
}
}
}
.alert("Subscription Active", isPresented: $showSuccessAlert) {
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") {
isPresented = false
}
@@ -200,50 +196,6 @@ struct FeatureComparisonView: View {
}
}
// MARK: - Purchase Handling
private func handlePurchase(_ product: Product) {
isProcessing = true
errorMessage = nil
Task {
do {
let transaction = try await storeKit.purchase(product)
await MainActor.run {
isProcessing = false
if transaction != nil {
showSuccessAlert = true
}
}
} catch {
await MainActor.run {
isProcessing = false
errorMessage = "Purchase failed: \(error.localizedDescription)"
}
}
}
}
private func handleRestore() {
isProcessing = true
errorMessage = nil
Task {
await storeKit.restorePurchases()
await MainActor.run {
isProcessing = false
if !storeKit.purchasedProductIDs.isEmpty {
showSuccessAlert = true
} else {
errorMessage = "No purchases found to restore"
}
}
}
}
}
// MARK: - Subscription Button

View File

@@ -94,6 +94,7 @@ class StoreKitManager: ObservableObject {
print("✅ StoreKit: Purchase successful for \(product.id)")
} catch {
print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)")
self.purchaseError = "Purchase successful but verification is pending. It will complete automatically."
}
return transaction

View File

@@ -32,7 +32,18 @@ class SubscriptionCacheWrapper: ObservableObject {
if let subscription = currentSubscription,
let expiresAt = subscription.expiresAt,
!expiresAt.isEmpty {
return "pro"
// Parse the date and check if subscription is still active
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let expiryDate = formatter.date(from: expiresAt) ?? ISO8601DateFormatter().date(from: expiresAt) {
if expiryDate > Date() {
return "pro"
}
// Expired fall through to StoreKit check
} else {
// Can't parse date but backend says there's a subscription
return "pro"
}
}
// Fallback to local StoreKit entitlements.

View File

@@ -0,0 +1,63 @@
import Foundation
import StoreKit
/// Shared purchase/restore logic used by FeatureComparisonView, UpgradeFeatureView, and UpgradePromptView.
/// Each view owns an instance via @StateObject and binds its published properties to the UI.
@MainActor
final class SubscriptionPurchaseHelper: ObservableObject {
@Published var isProcessing = false
@Published var errorMessage: String?
@Published var showSuccessAlert = false
@Published var selectedProduct: Product?
private var storeKit: StoreKitManager { StoreKitManager.shared }
func handlePurchase(_ product: Product) {
selectedProduct = product
isProcessing = true
errorMessage = nil
Task {
do {
let transaction = try await storeKit.purchase(product)
await MainActor.run {
isProcessing = false
if transaction != nil {
// Check if backend verification failed (purchase is valid but pending server confirmation)
if let backendError = storeKit.purchaseError {
errorMessage = backendError
} else {
showSuccessAlert = true
}
}
}
} catch {
await MainActor.run {
isProcessing = false
errorMessage = "Purchase failed: \(error.localizedDescription)"
}
}
}
}
func handleRestore() {
isProcessing = true
errorMessage = nil
Task {
await storeKit.restorePurchases()
await MainActor.run {
isProcessing = false
if !storeKit.purchasedProductIDs.isEmpty {
showSuccessAlert = true
} else {
errorMessage = "No purchases found to restore"
}
}
}
}
}

View File

@@ -7,13 +7,10 @@ struct UpgradeFeatureView: View {
let icon: String
@State private var showFeatureComparison = false
@State private var isProcessing = false
@State private var selectedProduct: Product?
@State private var errorMessage: String?
@State private var showSuccessAlert = false
@State private var isAnimating = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
private var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey]
@@ -155,11 +152,10 @@ struct UpgradeFeatureView: View {
ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
isSelected: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: purchaseHelper.isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
purchaseHelper.handlePurchase(product)
}
)
}
@@ -184,7 +180,7 @@ struct UpgradeFeatureView: View {
}
// Error Message
if let error = errorMessage {
if let error = purchaseHelper.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
@@ -211,7 +207,7 @@ struct UpgradeFeatureView: View {
if !isSubscribedOnOtherPlatform {
Button(action: {
handleRestore()
purchaseHelper.handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
@@ -227,7 +223,7 @@ struct UpgradeFeatureView: View {
.sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison)
}
.alert("Subscription Active", isPresented: $showSuccessAlert) {
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") { }
} message: {
Text("You now have full access to all Pro features!")
@@ -241,48 +237,6 @@ struct UpgradeFeatureView: View {
}
}
private func handlePurchase(_ product: Product) {
isProcessing = true
errorMessage = nil
Task {
do {
let transaction = try await storeKit.purchase(product)
await MainActor.run {
isProcessing = false
if transaction != nil {
showSuccessAlert = true
}
}
} catch {
await MainActor.run {
isProcessing = false
errorMessage = "Purchase failed: \(error.localizedDescription)"
}
}
}
}
private func handleRestore() {
isProcessing = true
errorMessage = nil
Task {
await storeKit.restorePurchases()
await MainActor.run {
isProcessing = false
if !storeKit.purchasedProductIDs.isEmpty {
showSuccessAlert = true
} else {
errorMessage = "No purchases found to restore"
}
}
}
}
}
// MARK: - Organic Feature Row

View File

@@ -124,11 +124,8 @@ struct UpgradePromptView: View {
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
@State private var showFeatureComparison = false
@State private var isProcessing = false
@State private var selectedProduct: Product?
@State private var errorMessage: String?
@State private var showSuccessAlert = false
@State private var isAnimating = false
var triggerData: UpgradeTriggerData? {
@@ -263,11 +260,10 @@ struct UpgradePromptView: View {
ForEach(storeKit.products, id: \.id) { product in
OrganicSubscriptionButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
isSelected: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: purchaseHelper.isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
purchaseHelper.handlePurchase(product)
}
)
}
@@ -292,7 +288,7 @@ struct UpgradePromptView: View {
}
// Error Message
if let error = errorMessage {
if let error = purchaseHelper.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
@@ -319,7 +315,7 @@ struct UpgradePromptView: View {
if !isSubscribedOnOtherPlatform {
Button(action: {
handleRestore()
purchaseHelper.handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
@@ -347,7 +343,7 @@ struct UpgradePromptView: View {
.sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison)
}
.alert("Subscription Active", isPresented: $showSuccessAlert) {
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") {
isPresented = false
}
@@ -364,48 +360,6 @@ struct UpgradePromptView: View {
}
}
private func handlePurchase(_ product: Product) {
isProcessing = true
errorMessage = nil
Task {
do {
let transaction = try await storeKit.purchase(product)
await MainActor.run {
isProcessing = false
if transaction != nil {
showSuccessAlert = true
}
}
} catch {
await MainActor.run {
isProcessing = false
errorMessage = "Purchase failed: \(error.localizedDescription)"
}
}
}
}
private func handleRestore() {
isProcessing = true
errorMessage = nil
Task {
await storeKit.restorePurchases()
await MainActor.run {
isProcessing = false
if !storeKit.purchasedProductIDs.isEmpty {
showSuccessAlert = true
} else {
errorMessage = "No purchases found to restore"
}
}
}
}
}
// MARK: - Organic Feature Row

View File

@@ -23,7 +23,6 @@ struct HomeNavigationCard: View {
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(title)
.font(.title3.weight(.semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Text(subtitle)

View File

@@ -181,8 +181,8 @@ struct MyCribIconView: View {
.fill(
LinearGradient(
colors: [
Color(red: 1.0, green: 0.64, blue: 0.28), // #FFA347
Color(red: 0.96, green: 0.51, blue: 0.20) // #F58233
backgroundColor.opacity(1.0),
backgroundColor.opacity(0.85)
],
startPoint: .top,
endPoint: .bottom

View File

@@ -1,11 +1,36 @@
import SwiftUI
struct StatView: View {
let icon: String
enum IconType {
case system(String)
case asset(String)
}
let icon: IconType
let value: String
let label: String
var color: Color = Color.appPrimary
/// Convenience initializer that accepts a plain string for backward compatibility.
/// Asset names are detected automatically; everything else is treated as an SF Symbol.
init(icon: String, value: String, label: String, color: Color = Color.appPrimary) {
if icon == "house_outline" {
self.icon = .asset(icon)
} else {
self.icon = .system(icon)
}
self.value = value
self.label = label
self.color = color
}
init(icon: IconType, value: String, label: String, color: Color = Color.appPrimary) {
self.icon = icon
self.value = value
self.label = label
self.color = color
}
var body: some View {
VStack(spacing: OrganicSpacing.compact) {
ZStack {
@@ -13,15 +38,16 @@ struct StatView: View {
.fill(color.opacity(0.1))
.frame(width: 52, height: 52)
if icon == "house_outline" {
Image("house_outline")
switch icon {
case .asset(let name):
Image(name)
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.foregroundColor(color)
} else {
Image(systemName: icon)
case .system(let name):
Image(systemName: name)
.font(.system(size: 22, weight: .semibold))
.foregroundColor(color)
}

View File

@@ -183,38 +183,6 @@ private struct PropertyIconView: View {
}
}
// MARK: - Pulse Ring Animation
private struct PulseRing: View {
@State private var isAnimating = false
@State private var isPulsing = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Circle()
.stroke(Color.appError.opacity(0.6), lineWidth: 2)
.frame(width: 60, height: 60)
.scaleEffect(isPulsing ? 1.15 : 1.0)
.opacity(isPulsing ? 0 : 1)
.animation(
reduceMotion
? .easeOut(duration: 1.5)
: isAnimating
? Animation.easeOut(duration: 1.5).repeatForever(autoreverses: false)
: .default,
value: isPulsing
)
.onAppear {
isAnimating = true
isPulsing = true
}
.onDisappear {
isAnimating = false
isPulsing = false
}
}
}
// MARK: - Primary Badge
private struct PrimaryBadgeView: View {

View File

@@ -4,7 +4,6 @@ import ComposeApp
// MARK: - Share Code Card
struct ShareCodeCard: View {
let shareCode: ShareCodeResponse?
let residenceName: String
let isGeneratingCode: Bool
let isGeneratingPackage: Bool
let onGenerateCode: () -> Void
@@ -131,7 +130,6 @@ struct ShareCodeCard: View {
#Preview {
ShareCodeCard(
shareCode: nil,
residenceName: "My Home",
isGeneratingCode: false,
isGeneratingPackage: false,
onGenerateCode: {},

View File

@@ -182,49 +182,63 @@ struct DynamicTaskCard: View {
switch buttonType {
case "mark_in_progress":
Button {
print("🔵 Mark In Progress tapped for task: \(task.id)")
#if DEBUG
print("Mark In Progress tapped for task: \(task.id)")
#endif
onMarkInProgress()
} label: {
Label("Mark Task In Progress", systemImage: "play.circle")
}
case "complete":
Button {
print("✅ Complete tapped for task: \(task.id)")
#if DEBUG
print("Complete tapped for task: \(task.id)")
#endif
onComplete()
} label: {
Label("Complete Task", systemImage: "checkmark.circle")
}
case "edit":
Button {
print("✏️ Edit tapped for task: \(task.id)")
#if DEBUG
print("Edit tapped for task: \(task.id)")
#endif
onEdit()
} label: {
Label("Edit Task", systemImage: "pencil")
}
case "cancel":
Button(role: .destructive) {
print("❌ Cancel tapped for task: \(task.id)")
#if DEBUG
print("Cancel tapped for task: \(task.id)")
#endif
onCancel()
} label: {
Label("Cancel Task", systemImage: "xmark.circle")
}
case "uncancel":
Button {
print("🔄 Restore tapped for task: \(task.id)")
#if DEBUG
print("Restore tapped for task: \(task.id)")
#endif
onUncancel()
} label: {
Label("Restore Task", systemImage: "arrow.uturn.backward.circle")
}
case "archive":
Button {
print("📦 Archive tapped for task: \(task.id)")
#if DEBUG
print("Archive tapped for task: \(task.id)")
#endif
onArchive()
} label: {
Label("Archive Task", systemImage: "archivebox")
}
case "unarchive":
Button {
print("📤 Unarchive tapped for task: \(task.id)")
#if DEBUG
print("Unarchive tapped for task: \(task.id)")
#endif
onUnarchive()
} label: {
Label("Unarchive Task", systemImage: "arrow.up.bin")

View File

@@ -1,8 +1,7 @@
import SwiftUI
import ComposeApp
// TODO: (P5) Each action button that performs an API call creates its own @StateObject TaskViewModel instance.
// This is potentially wasteful consider accepting a shared TaskViewModel from the parent view instead.
// Action buttons accept a shared TaskViewModel from the parent view to avoid redundant instances.
// MARK: - Edit Task Button
struct EditTaskButton: View {
@@ -29,7 +28,7 @@ struct CancelTaskButton: View {
let onCompletion: () -> Void
let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel()
let viewModel: TaskViewModel
@State private var showConfirmation = false
var body: some View {
@@ -54,7 +53,7 @@ struct CancelTaskButton: View {
}
}
} message: {
Text("Are you sure you want to cancel this task? This action cannot be undone.")
Text("Are you sure you want to cancel this task? You can undo this later.")
}
}
}
@@ -65,7 +64,7 @@ struct UncancelTaskButton: View {
let onCompletion: () -> Void
let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel()
let viewModel: TaskViewModel
var body: some View {
Button(action: {
@@ -92,7 +91,7 @@ struct MarkInProgressButton: View {
let onCompletion: () -> Void
let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel()
let viewModel: TaskViewModel
var body: some View {
Button(action: {
@@ -148,7 +147,7 @@ struct ArchiveTaskButton: View {
let onCompletion: () -> Void
let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel()
let viewModel: TaskViewModel
@State private var showConfirmation = false
var body: some View {
@@ -184,7 +183,7 @@ struct UnarchiveTaskButton: View {
let onCompletion: () -> Void
let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel()
let viewModel: TaskViewModel
var body: some View {
Button(action: {

View File

@@ -82,7 +82,7 @@ struct TasksSection: View {
}
.scrollTargetBehavior(.viewAligned)
}
.frame(height: 500)
.frame(height: min(500, UIScreen.main.bounds.height * 0.55))
}
}
}

View File

@@ -2,12 +2,10 @@ import SwiftUI
import ComposeApp
struct AllTasksView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var taskViewModel = TaskViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var showAddTask = false
@State private var showEditTask = false
@State private var showingUpgradePrompt = false
@State private var selectedTaskForEdit: TaskResponse?
@State private var selectedTaskForComplete: TaskResponse?
@@ -50,10 +48,11 @@ struct AllTasksView: View {
residences: residenceViewModel.myResidences?.residences ?? []
)
}
.sheet(isPresented: $showEditTask) {
if let task = selectedTaskForEdit {
EditTaskView(task: task, isPresented: $showEditTask)
}
.sheet(item: $selectedTaskForEdit) { task in
EditTaskView(task: task, isPresented: Binding(
get: { selectedTaskForEdit != nil },
set: { if !$0 { selectedTaskForEdit = nil } }
))
}
.sheet(item: $selectedTaskForComplete, onDismiss: {
if let task = pendingCompletedTask {
@@ -138,19 +137,15 @@ struct AllTasksView: View {
}
}
}
.onChange(of: tasksResponse) { response in
.onChange(of: tasksResponse) { _, response in
if let taskId = pendingTaskId, let response = response {
navigateToTaskInKanban(taskId: taskId, response: response)
}
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag()
loadAllTasks(forceRefresh: true)
}
}
}
// P-5: Removed redundant .onChange(of: scenePhase) handler.
// iOSApp.swift already handles foreground refresh and widget dirty-flag
// processing globally, so per-view scenePhase handlers fire duplicate
// network requests.
}
@ViewBuilder
@@ -185,7 +180,6 @@ struct AllTasksView: View {
column: column,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: { task in
selectedTaskForCancel = task
@@ -240,7 +234,7 @@ struct AllTasksView: View {
.padding(16)
}
.scrollTargetBehavior(.viewAligned)
.onChange(of: scrollToColumnIndex) { columnIndex in
.onChange(of: scrollToColumnIndex) { _, columnIndex in
if let columnIndex = columnIndex {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(columnIndex, anchor: .leading)
@@ -328,12 +322,16 @@ struct AllTasksView: View {
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
for (index, column) in response.columns.enumerated() {
if column.tasks.contains(where: { $0.id == taskId }) {
if let task = column.tasks.first(where: { $0.id == taskId }) {
pendingTaskId = nil
PushNotificationManager.shared.clearPendingNavigation()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.scrollToColumnIndex = index
}
// Open the edit sheet for this task so the user sees its details
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
self.selectedTaskForEdit = task
}
return
}
}

View File

@@ -2,6 +2,11 @@ import SwiftUI
import PhotosUI
import ComposeApp
/// Wrapper to retain the Kotlin ViewModel via @StateObject
private class CompletionViewModelHolder: ObservableObject {
let vm = ComposeApp.TaskCompletionViewModel()
}
struct CompleteTaskView: View {
let task: TaskResponse
let onComplete: (TaskResponse?) -> Void // Pass back updated task
@@ -9,7 +14,8 @@ struct CompleteTaskView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var taskViewModel = TaskViewModel()
@StateObject private var contractorViewModel = ContractorViewModel()
private let completionViewModel = ComposeApp.TaskCompletionViewModel()
@StateObject private var completionHolder = CompletionViewModelHolder()
private var completionViewModel: ComposeApp.TaskCompletionViewModel { completionHolder.vm }
@State private var completedByName: String = ""
@State private var actualCost: String = ""
@State private var notes: String = ""
@@ -200,7 +206,7 @@ struct CompleteTaskView: View {
}
.buttonStyle(.bordered)
}
.onChange(of: selectedItems) { newItems in
.onChange(of: selectedItems) { _, newItems in
Task {
selectedImages = []
for item in newItems {

View File

@@ -262,7 +262,7 @@ struct CompletionHistoryCard: View {
.foregroundColor(Color.appPrimary)
}
Text("$\(cost)")
Text(cost.toCurrency())
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
}

View File

@@ -100,7 +100,6 @@ struct TaskFormView: View {
if needsResidenceSelection, let residences = residences {
Section {
Picker(L10n.Tasks.property, selection: $selectedResidence) {
Text(L10n.Tasks.selectProperty).tag(nil as ResidenceResponse?)
ForEach(residences, id: \.id) { residence in
Text(residence.name).tag(residence as ResidenceResponse?)
}
@@ -111,10 +110,6 @@ struct TaskFormView: View {
}
} header: {
Text(L10n.Tasks.property)
} footer: {
Text(L10n.Tasks.required)
.font(.caption)
.foregroundColor(Color.appError)
}
.sectionBackground()
}
@@ -168,7 +163,7 @@ struct TaskFormView: View {
VStack(alignment: .leading, spacing: 8) {
TextField(L10n.Tasks.titleLabel, text: $title)
.focused($focusedField, equals: .title)
.onChange(of: title) { newValue in
.onChange(of: title) { _, newValue in
updateSuggestions(query: newValue)
}
@@ -190,7 +185,6 @@ struct TaskFormView: View {
TextField(L10n.Tasks.descriptionOptional, text: $description, axis: .vertical)
.lineLimit(3...6)
.focused($focusedField, equals: .description)
.keyboardDismissToolbar()
} header: {
Text(L10n.Tasks.taskDetails)
} footer: {
@@ -219,7 +213,7 @@ struct TaskFormView: View {
Text(frequency.displayName).tag(frequency as TaskFrequency?)
}
}
.onChange(of: selectedFrequency) { newFrequency in
.onChange(of: selectedFrequency) { _, newFrequency in
// Clear interval days if not Custom frequency
if newFrequency?.name.lowercased() != "custom" {
intervalDays = ""
@@ -231,7 +225,6 @@ struct TaskFormView: View {
TextField(L10n.Tasks.customInterval, text: $intervalDays)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
.keyboardDismissToolbar()
}
DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date)
@@ -266,7 +259,6 @@ struct TaskFormView: View {
.focused($focusedField, equals: .estimatedCost)
}
.sectionBackground()
.keyboardDismissToolbar()
if let errorMessage = viewModel.errorMessage {
Section {
@@ -290,11 +282,20 @@ struct TaskFormView: View {
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(L10n.Common.save) {
Button(isEditMode ? L10n.Common.save : L10n.Common.add) {
submitForm()
}
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
focusedField = nil
}
.foregroundColor(Color.appPrimary)
.fontWeight(.medium)
}
}
.onAppear {
// Track screen view for new tasks
@@ -306,17 +307,17 @@ struct TaskFormView: View {
setDefaults()
}
}
.onChange(of: dataManager.lookupsInitialized) { initialized in
.onChange(of: dataManager.lookupsInitialized) { _, initialized in
if initialized {
setDefaults()
}
}
.onChange(of: viewModel.taskCreated) { created in
.onChange(of: viewModel.taskCreated) { _, created in
if created {
isPresented = false
}
}
.onChange(of: viewModel.errorMessage) { errorMessage in
.onChange(of: viewModel.errorMessage) { _, errorMessage in
if let errorMessage = errorMessage, !errorMessage.isEmpty {
errorAlert = ErrorAlertInfo(message: errorMessage)
}

View File

@@ -27,7 +27,7 @@ struct TaskSuggestionsView: View {
// Category-colored icon
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
.font(.system(size: 18))
.foregroundColor(categoryColor(for: template.categoryName))
.foregroundColor(Color.taskCategoryColor(for: template.categoryName))
.frame(width: 28, height: 28)
// Task info
@@ -78,18 +78,6 @@ struct TaskSuggestionsView: View {
.naturalShadow(.medium)
}
private func categoryColor(for categoryName: String) -> Color {
switch categoryName.lowercased() {
case "plumbing": return Color.appSecondary
case "safety", "electrical": return Color.appError
case "hvac": return Color.appPrimary
case "appliances": return Color.appAccent
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
case "interior": return Color(hex: "#AF52DE") ?? .purple
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
default: return Color.appPrimary
}
}
}
#Preview {

View File

@@ -106,7 +106,7 @@ struct TaskTemplatesBrowserView: View {
// Category icon
Image(systemName: categoryIcon(for: categoryGroup.categoryName))
.font(.system(size: 18))
.foregroundColor(categoryColor(for: categoryGroup.categoryName))
.foregroundColor(Color.taskCategoryColor(for: categoryGroup.categoryName))
.frame(width: 28, height: 28)
// Category name
@@ -180,7 +180,7 @@ struct TaskTemplatesBrowserView: View {
// Icon
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
.font(.system(size: 16))
.foregroundColor(categoryColor(for: template.categoryName))
.foregroundColor(Color.taskCategoryColor(for: template.categoryName))
.frame(width: 24, height: 24)
// Task info
@@ -225,18 +225,6 @@ struct TaskTemplatesBrowserView: View {
}
}
private func categoryColor(for categoryName: String) -> Color {
switch categoryName.lowercased() {
case "plumbing": return Color.appSecondary
case "safety", "electrical": return Color.appError
case "hvac": return Color.appPrimary
case "appliances": return Color.appAccent
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
case "interior": return Color(hex: "#AF52DE") ?? .purple
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
default: return Color.appPrimary
}
}
}
#Preview {

View File

@@ -10,6 +10,8 @@ struct iOSApp: App {
@StateObject private var residenceSharingManager = ResidenceSharingManager.shared
@Environment(\.scenePhase) private var scenePhase
@State private var deepLinkResetToken: String?
/// Tracks foreground refresh tasks so they can be cancelled on subsequent transitions
@State private var foregroundTask: Task<Void, Never>?
@State private var pendingImportURL: URL?
@State private var pendingImportType: CaseraPackageType = .contractor
@State private var showImportConfirmation: Bool = false
@@ -59,7 +61,7 @@ struct iOSApp: App {
.onOpenURL { url in
handleIncomingURL(url: url)
}
.onChange(of: scenePhase) { newPhase in
.onChange(of: scenePhase) { _, newPhase in
guard !UITestRuntime.isEnabled else { return }
if newPhase == .active {
@@ -73,27 +75,22 @@ struct iOSApp: App {
// Check and register device token when app becomes active
PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded()
// Refresh lookups/static data when app becomes active
Task {
// Cancel any previous foreground refresh task before starting a new one
foregroundTask?.cancel()
foregroundTask = Task { @MainActor in
// Refresh lookups/static data
_ = try? await APILayer.shared.initializeLookups()
}
// Process any pending widget actions (task completions, mark in-progress)
Task { @MainActor in
// Process any pending widget actions (task completions, mark in-progress)
WidgetActionProcessor.shared.processPendingActions()
}
// Check if widget completed a task - refresh data globally
if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag()
Task {
// Refresh tasks - summary is calculated client-side from kanban data
// Check if widget completed a task - refresh data globally
if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag()
let result = try? await APILayer.shared.getTasks(forceRefresh: true)
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
let data = success.data {
// Update widget cache
WidgetDataManager.shared.saveTasks(from: data)
// Summary is calculated by DataManager.setAllTasks() -> refreshSummaryFromKanban()
}
}
}
@@ -237,11 +234,21 @@ struct iOSApp: App {
.appendingPathExtension("casera")
try data.write(to: tempURL)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String {
pendingImportType = typeString == "residence" ? .residence : .contractor
} else {
pendingImportType = .contractor
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String {
pendingImportType = typeString == "residence" ? .residence : .contractor
} else {
print("iOSApp: Casera file is valid JSON but missing 'type' field, defaulting to contractor")
pendingImportType = .contractor
}
} catch {
print("iOSApp: Failed to parse casera file JSON: \(error)")
if accessing {
url.stopAccessingSecurityScopedResource()
}
contractorSharingManager.importError = "The file appears to be corrupted and could not be read."
return
}
pendingImportURL = tempURL
@@ -262,25 +269,35 @@ struct iOSApp: App {
/// Handles casera:// deep links
private func handleDeepLink(url: URL) {
// Handle casera://reset-password?token=xxx
guard url.host == "reset-password" else {
#if DEBUG
print("Unrecognized deep link host: \(url.host ?? "nil")")
#endif
return
}
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
let host = url.host
// Parse token from query parameters
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let queryItems = components.queryItems,
let token = queryItems.first(where: { $0.name == "token" })?.value {
switch host {
case "reset-password":
if let token = components.queryItems?.first(where: { $0.name == "token" })?.value {
#if DEBUG
print("Reset token extracted: \(token)")
#endif
deepLinkResetToken = token
}
case "task":
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
let id = Int(idString) {
NotificationCenter.default.post(name: .navigateToTask, object: nil, userInfo: ["taskId": id])
}
case "residence":
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
let id = Int(idString) {
NotificationCenter.default.post(name: .navigateToResidence, object: nil, userInfo: ["residenceId": id])
}
case "document":
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
let id = Int(idString) {
NotificationCenter.default.post(name: .navigateToDocument, object: nil, userInfo: ["documentId": id])
}
default:
#if DEBUG
print("Reset token extracted: \(token)")
#endif
deepLinkResetToken = token
} else {
#if DEBUG
print("No token found in deep link")
print("Unrecognized deep link host: \(host ?? "nil")")
#endif
}
}