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 { func perform() async throws -> some IntentResult {
print("CompleteTaskIntent: Starting completion for task \(taskId)") print("CompleteTaskIntent: Starting completion for task \(taskId)")
// Mark task as pending completion immediately (optimistic UI) // Check auth BEFORE marking pending if auth fails the task should remain visible
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
guard let token = WidgetActionManager.shared.getAuthToken() else { guard let token = WidgetActionManager.shared.getAuthToken() else {
print("CompleteTaskIntent: No auth token available") print("CompleteTaskIntent: No auth token available")
WidgetCenter.shared.reloadTimelines(ofKind: "Casera") WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
@@ -59,6 +53,12 @@ struct CompleteTaskIntent: AppIntent {
return .result() 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 // Make API call to complete the task
let success = await WidgetAPIClient.quickCompleteTask( let success = await WidgetAPIClient.quickCompleteTask(
taskId: taskId, taskId: taskId,

View File

@@ -12,7 +12,5 @@ import SwiftUI
struct CaseraBundle: WidgetBundle { struct CaseraBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
Casera() 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 import AppIntents
// MARK: - Date Formatting Helper // 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 /// Parses date strings in either yyyy-MM-dd or ISO8601 (RFC3339) format
/// and returns a user-friendly string like "Today" or "in X days" /// and returns a user-friendly string like "Today" or "in X days"
private func formatWidgetDate(_ dateString: String) -> String { private func formatWidgetDate(_ dateString: String) -> String {
@@ -17,20 +39,15 @@ private func formatWidgetDate(_ dateString: String) -> String {
var date: Date? var date: Date?
// Try parsing as yyyy-MM-dd first // Try parsing as yyyy-MM-dd first
let dateOnlyFormatter = DateFormatter() date = WidgetDateFormatters.dateOnly.date(from: dateString)
dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
date = dateOnlyFormatter.date(from: dateString)
// Try parsing as ISO8601 (RFC3339) if that fails // Try parsing as ISO8601 (RFC3339) if that fails
if date == nil { if date == nil {
let isoFormatter = ISO8601DateFormatter() date = WidgetDateFormatters.iso8601WithFractional.date(from: dateString)
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
date = isoFormatter.date(from: dateString)
// Try without fractional seconds // Try without fractional seconds
if date == nil { if date == nil {
isoFormatter.formatOptions = [.withInternetDateTime] date = WidgetDateFormatters.iso8601.date(from: dateString)
date = isoFormatter.date(from: dateString)
} }
} }
@@ -179,9 +196,11 @@ struct Provider: AppIntentTimelineProvider {
let tasks = CacheManager.getUpcomingTasks() let tasks = CacheManager.getUpcomingTasks()
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget() 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 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( let entry = SimpleEntry(
date: currentDate, date: currentDate,
configuration: configuration, configuration: configuration,

View File

@@ -198,16 +198,15 @@ class PreviewViewController: UIViewController, QLPreviewingController {
func preparePreviewOfFile(at url: URL) async throws { func preparePreviewOfFile(at url: URL) async throws {
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)") 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 data = try Data(contentsOf: url)
let decoder = JSONDecoder()
// Detect package type first let envelope = try? decoder.decode(PackageTypeEnvelope.self, from: data)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String, if envelope?.type == "residence" {
typeString == "residence" {
currentPackageType = .residence currentPackageType = .residence
let decoder = JSONDecoder()
let residence = try decoder.decode(ResidencePreviewData.self, from: data) let residence = try decoder.decode(ResidencePreviewData.self, from: data)
self.residenceData = residence self.residenceData = residence
print("CaseraQLPreview: Parsed residence: \(residence.residenceName)") print("CaseraQLPreview: Parsed residence: \(residence.residenceName)")
@@ -218,7 +217,6 @@ class PreviewViewController: UIViewController, QLPreviewingController {
} else { } else {
currentPackageType = .contractor currentPackageType = .contractor
let decoder = JSONDecoder()
let contractor = try decoder.decode(ContractorPreviewData.self, from: data) let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
self.contractorData = contractor self.contractorData = contractor
print("CaseraQLPreview: Parsed contractor: \(contractor.name)") 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 // MARK: - Data Model
struct ContractorPreviewData: Codable { struct ContractorPreviewData: Codable {

View File

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

View File

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

View File

@@ -90,7 +90,6 @@ final class BackgroundTaskManager {
} }
/// Perform the actual data refresh /// Perform the actual data refresh
@MainActor
private func performDataRefresh() async -> Bool { private func performDataRefresh() async -> Bool {
// Check if user is authenticated // Check if user is authenticated
guard let token = TokenStorage.shared.getToken(), !token.isEmpty else { guard let token = TokenStorage.shared.getToken(), !token.isEmpty else {

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,9 @@ class ContractorViewModel: ObservableObject {
// MARK: - Private Properties // MARK: - Private Properties
private var cancellables = Set<AnyCancellable>() 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 // MARK: - Initialization
@@ -28,6 +31,20 @@ class ContractorViewModel: ObservableObject {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] contractors in .sink { [weak self] contractors in
self?.contractors = contractors 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) .store(in: &cancellables)
} }
@@ -99,10 +116,12 @@ class ContractorViewModel: ObservableObject {
do { do {
let result = try await APILayer.shared.createContractor(request: request) 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.successMessage = "Contractor added successfully"
self.isCreating = false 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) completion(true)
} else if let error = ApiResultBridge.error(from: result) { } else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message) self.errorMessage = ErrorMessageParser.parse(error.message)
@@ -129,10 +148,12 @@ class ContractorViewModel: ObservableObject {
do { do {
let result = try await APILayer.shared.updateContractor(id: id, request: request) 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.successMessage = "Contractor updated successfully"
self.isUpdating = false 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) completion(true)
} else if let error = ApiResultBridge.error(from: result) { } else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message) self.errorMessage = ErrorMessageParser.parse(error.message)
@@ -186,8 +207,10 @@ class ContractorViewModel: ObservableObject {
do { do {
let result = try await APILayer.shared.toggleFavorite(id: id) let result = try await APILayer.shared.toggleFavorite(id: id)
if result is ApiResultSuccess<Contractor> { if let success = result as? ApiResultSuccess<Contractor> {
// 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) completion(true)
} else if let error = ApiResultBridge.error(from: result) { } else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message) self.errorMessage = ErrorMessageParser.parse(error.message)
@@ -207,4 +230,21 @@ class ContractorViewModel: ObservableObject {
errorMessage = nil errorMessage = nil
successMessage = 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: { onRefresh: {
loadContractors(forceRefresh: true) viewModel.loadContractors(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
}, },
onRetry: { onRetry: {
loadContractors() loadContractors()

View File

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

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,7 +96,6 @@ class DataManagerObservable: ObservableObject {
// Authentication - authToken // Authentication - authToken
let authTokenTask = Task { [weak self] in let authTokenTask = Task { [weak self] in
for await token in DataManager.shared.authToken { for await token in DataManager.shared.authToken {
await MainActor.run {
guard let self else { return } guard let self else { return }
let previousToken = self.authToken let previousToken = self.authToken
let wasAuthenticated = previousToken != nil let wasAuthenticated = previousToken != nil
@@ -122,286 +121,236 @@ class DataManagerObservable: ObservableObject {
} }
} }
} }
}
observationTasks.append(authTokenTask) observationTasks.append(authTokenTask)
// Authentication - currentUser // Authentication - currentUser
let currentUserTask = Task { [weak self] in let currentUserTask = Task { [weak self] in
for await user in DataManager.shared.currentUser { for await user in DataManager.shared.currentUser {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.currentUser = user self.currentUser = user
} }
} }
}
observationTasks.append(currentUserTask) observationTasks.append(currentUserTask)
// Theme // Theme
let themeIdTask = Task { [weak self] in let themeIdTask = Task { [weak self] in
for await id in DataManager.shared.themeId { for await id in DataManager.shared.themeId {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.themeId = id self.themeId = id
} }
} }
}
observationTasks.append(themeIdTask) observationTasks.append(themeIdTask)
// Residences // Residences
let residencesTask = Task { [weak self] in let residencesTask = Task { [weak self] in
for await list in DataManager.shared.residences { for await list in DataManager.shared.residences {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.residences = list self.residences = list
} }
} }
}
observationTasks.append(residencesTask) observationTasks.append(residencesTask)
// MyResidences // MyResidences
let myResidencesTask = Task { [weak self] in let myResidencesTask = Task { [weak self] in
for await response in DataManager.shared.myResidences { for await response in DataManager.shared.myResidences {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.myResidences = response self.myResidences = response
} }
} }
}
observationTasks.append(myResidencesTask) observationTasks.append(myResidencesTask)
// TotalSummary // TotalSummary
let totalSummaryTask = Task { [weak self] in let totalSummaryTask = Task { [weak self] in
for await summary in DataManager.shared.totalSummary { for await summary in DataManager.shared.totalSummary {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.totalSummary = summary self.totalSummary = summary
} }
} }
}
observationTasks.append(totalSummaryTask) observationTasks.append(totalSummaryTask)
// ResidenceSummaries // ResidenceSummaries
let residenceSummariesTask = Task { [weak self] in let residenceSummariesTask = Task { [weak self] in
for await summaries in DataManager.shared.residenceSummaries { for await summaries in DataManager.shared.residenceSummaries {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.residenceSummaries = self.convertIntMap(summaries) self.residenceSummaries = self.convertIntMap(summaries)
} }
} }
}
observationTasks.append(residenceSummariesTask) observationTasks.append(residenceSummariesTask)
// AllTasks // AllTasks
let allTasksTask = Task { [weak self] in let allTasksTask = Task { [weak self] in
for await tasks in DataManager.shared.allTasks { for await tasks in DataManager.shared.allTasks {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.allTasks = tasks self.allTasks = tasks
self.recomputeActiveTasks()
// Save to widget shared container (debounced) // Save to widget shared container (debounced)
if let tasks = tasks { if let tasks = tasks {
self.debouncedWidgetSave(tasks: tasks) self.debouncedWidgetSave(tasks: tasks)
} }
} }
} }
}
observationTasks.append(allTasksTask) observationTasks.append(allTasksTask)
// TasksByResidence // TasksByResidence
let tasksByResidenceTask = Task { [weak self] in let tasksByResidenceTask = Task { [weak self] in
for await tasks in DataManager.shared.tasksByResidence { for await tasks in DataManager.shared.tasksByResidence {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.tasksByResidence = self.convertIntMap(tasks) self.tasksByResidence = self.convertIntMap(tasks)
} }
} }
}
observationTasks.append(tasksByResidenceTask) observationTasks.append(tasksByResidenceTask)
// Documents // Documents
let documentsTask = Task { [weak self] in let documentsTask = Task { [weak self] in
for await docs in DataManager.shared.documents { for await docs in DataManager.shared.documents {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.documents = docs self.documents = docs
} }
} }
}
observationTasks.append(documentsTask) observationTasks.append(documentsTask)
// DocumentsByResidence // DocumentsByResidence
let documentsByResidenceTask = Task { [weak self] in let documentsByResidenceTask = Task { [weak self] in
for await docs in DataManager.shared.documentsByResidence { for await docs in DataManager.shared.documentsByResidence {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.documentsByResidence = self.convertIntArrayMap(docs) self.documentsByResidence = self.convertIntArrayMap(docs)
} }
} }
}
observationTasks.append(documentsByResidenceTask) observationTasks.append(documentsByResidenceTask)
// Contractors // Contractors
let contractorsTask = Task { [weak self] in let contractorsTask = Task { [weak self] in
for await list in DataManager.shared.contractors { for await list in DataManager.shared.contractors {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.contractors = list self.contractors = list
} }
} }
}
observationTasks.append(contractorsTask) observationTasks.append(contractorsTask)
// Subscription // Subscription
let subscriptionTask = Task { [weak self] in let subscriptionTask = Task { [weak self] in
for await sub in DataManager.shared.subscription { for await sub in DataManager.shared.subscription {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.subscription = sub self.subscription = sub
} }
} }
}
observationTasks.append(subscriptionTask) observationTasks.append(subscriptionTask)
// UpgradeTriggers // UpgradeTriggers
let upgradeTriggersTask = Task { [weak self] in let upgradeTriggersTask = Task { [weak self] in
for await triggers in DataManager.shared.upgradeTriggers { for await triggers in DataManager.shared.upgradeTriggers {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.upgradeTriggers = self.convertStringMap(triggers) self.upgradeTriggers = self.convertStringMap(triggers)
} }
} }
}
observationTasks.append(upgradeTriggersTask) observationTasks.append(upgradeTriggersTask)
// FeatureBenefits // FeatureBenefits
let featureBenefitsTask = Task { [weak self] in let featureBenefitsTask = Task { [weak self] in
for await benefits in DataManager.shared.featureBenefits { for await benefits in DataManager.shared.featureBenefits {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.featureBenefits = benefits self.featureBenefits = benefits
} }
} }
}
observationTasks.append(featureBenefitsTask) observationTasks.append(featureBenefitsTask)
// Promotions // Promotions
let promotionsTask = Task { [weak self] in let promotionsTask = Task { [weak self] in
for await promos in DataManager.shared.promotions { for await promos in DataManager.shared.promotions {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.promotions = promos self.promotions = promos
} }
} }
}
observationTasks.append(promotionsTask) observationTasks.append(promotionsTask)
// Lookups - ResidenceTypes // Lookups - ResidenceTypes
let residenceTypesTask = Task { [weak self] in let residenceTypesTask = Task { [weak self] in
for await types in DataManager.shared.residenceTypes { for await types in DataManager.shared.residenceTypes {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.residenceTypes = types self.residenceTypes = types
} }
} }
}
observationTasks.append(residenceTypesTask) observationTasks.append(residenceTypesTask)
// Lookups - TaskFrequencies // Lookups - TaskFrequencies
let taskFrequenciesTask = Task { [weak self] in let taskFrequenciesTask = Task { [weak self] in
for await items in DataManager.shared.taskFrequencies { for await items in DataManager.shared.taskFrequencies {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.taskFrequencies = items self.taskFrequencies = items
} }
} }
}
observationTasks.append(taskFrequenciesTask) observationTasks.append(taskFrequenciesTask)
// Lookups - TaskPriorities // Lookups - TaskPriorities
let taskPrioritiesTask = Task { [weak self] in let taskPrioritiesTask = Task { [weak self] in
for await items in DataManager.shared.taskPriorities { for await items in DataManager.shared.taskPriorities {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.taskPriorities = items self.taskPriorities = items
} }
} }
}
observationTasks.append(taskPrioritiesTask) observationTasks.append(taskPrioritiesTask)
// Lookups - TaskCategories // Lookups - TaskCategories
let taskCategoriesTask = Task { [weak self] in let taskCategoriesTask = Task { [weak self] in
for await items in DataManager.shared.taskCategories { for await items in DataManager.shared.taskCategories {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.taskCategories = items self.taskCategories = items
} }
} }
}
observationTasks.append(taskCategoriesTask) observationTasks.append(taskCategoriesTask)
// Lookups - ContractorSpecialties // Lookups - ContractorSpecialties
let contractorSpecialtiesTask = Task { [weak self] in let contractorSpecialtiesTask = Task { [weak self] in
for await items in DataManager.shared.contractorSpecialties { for await items in DataManager.shared.contractorSpecialties {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.contractorSpecialties = items self.contractorSpecialties = items
} }
} }
}
observationTasks.append(contractorSpecialtiesTask) observationTasks.append(contractorSpecialtiesTask)
// Task Templates // Task Templates
let taskTemplatesTask = Task { [weak self] in let taskTemplatesTask = Task { [weak self] in
for await items in DataManager.shared.taskTemplates { for await items in DataManager.shared.taskTemplates {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.taskTemplates = items self.taskTemplates = items
} }
} }
}
observationTasks.append(taskTemplatesTask) observationTasks.append(taskTemplatesTask)
// Task Templates Grouped // Task Templates Grouped
let taskTemplatesGroupedTask = Task { [weak self] in let taskTemplatesGroupedTask = Task { [weak self] in
for await response in DataManager.shared.taskTemplatesGrouped { for await response in DataManager.shared.taskTemplatesGrouped {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.taskTemplatesGrouped = response self.taskTemplatesGrouped = response
} }
} }
}
observationTasks.append(taskTemplatesGroupedTask) observationTasks.append(taskTemplatesGroupedTask)
// Metadata - isInitialized // Metadata - isInitialized
let isInitializedTask = Task { [weak self] in let isInitializedTask = Task { [weak self] in
for await initialized in DataManager.shared.isInitialized { for await initialized in DataManager.shared.isInitialized {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.isInitialized = initialized.boolValue self.isInitialized = initialized.boolValue
} }
} }
}
observationTasks.append(isInitializedTask) observationTasks.append(isInitializedTask)
// Metadata - lookupsInitialized // Metadata - lookupsInitialized
let lookupsInitializedTask = Task { [weak self] in let lookupsInitializedTask = Task { [weak self] in
for await initialized in DataManager.shared.lookupsInitialized { for await initialized in DataManager.shared.lookupsInitialized {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.lookupsInitialized = initialized.boolValue self.lookupsInitialized = initialized.boolValue
} }
} }
}
observationTasks.append(lookupsInitializedTask) observationTasks.append(lookupsInitializedTask)
// Metadata - lastSyncTime // Metadata - lastSyncTime
let lastSyncTimeTask = Task { [weak self] in let lastSyncTimeTask = Task { [weak self] in
for await time in DataManager.shared.lastSyncTime { for await time in DataManager.shared.lastSyncTime {
await MainActor.run {
guard let self else { return } guard let self else { return }
self.lastSyncTime = time.int64Value self.lastSyncTime = time.int64Value
} }
} }
}
observationTasks.append(lastSyncTimeTask) observationTasks.append(lastSyncTimeTask)
} }
@@ -516,6 +465,9 @@ class DataManagerObservable: ObservableObject {
} }
// MARK: - Convenience Lookup Methods // 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 /// Get residence type by ID
func getResidenceType(id: Int32?) -> ResidenceType? { func getResidenceType(id: Int32?) -> ResidenceType? {
@@ -579,9 +531,17 @@ class DataManagerObservable: ObservableObject {
// MARK: - Task Stats (Single Source of Truth) // MARK: - Task Stats (Single Source of Truth)
// Uses API column names + shared calculateMetrics function // Uses API column names + shared calculateMetrics function
/// Active tasks (excludes completed and cancelled) /// Active tasks (excludes completed and cancelled).
var activeTasks: [TaskResponse] { /// Computed once when `allTasks` changes and cached to avoid
guard let response = allTasks else { return [] } /// 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] = [] var tasks: [TaskResponse] = []
for column in response.columns { for column in response.columns {
let columnName = column.name.lowercased() let columnName = column.name.lowercased()
@@ -590,7 +550,7 @@ class DataManagerObservable: ObservableObject {
} }
tasks.append(contentsOf: column.tasks) tasks.append(contentsOf: column.tasks)
} }
return tasks activeTasks = tasks
} }
/// Get tasks from a specific column by name /// Get tasks from a specific column by name

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ struct DocumentDetailView: View {
@State private var downloadProgress: Double = 0 @State private var downloadProgress: Double = 0
@State private var downloadError: String? @State private var downloadError: String?
@State private var downloadedFileURL: URL? @State private var downloadedFileURL: URL?
@State private var showShareSheet = false
var body: some View { var body: some View {
ZStack { ZStack {
@@ -43,6 +42,8 @@ struct DocumentDetailView: View {
.navigationDestination(isPresented: $navigateToEdit) { .navigationDestination(isPresented: $navigateToEdit) {
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess { if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
EditDocumentView(document: successState.document) EditDocumentView(document: successState.document)
} else {
Color.clear.onAppear { navigateToEdit = false }
} }
} }
.toolbar { .toolbar {
@@ -82,7 +83,7 @@ struct DocumentDetailView: View {
deleteSucceeded = true deleteSucceeded = true
} }
} }
.onChange(of: deleteSucceeded) { succeeded in .onChange(of: deleteSucceeded) { _, succeeded in
if succeeded { if succeeded {
dismiss() dismiss()
} }
@@ -94,9 +95,14 @@ struct DocumentDetailView: View {
selectedIndex: $selectedImageIndex, selectedIndex: $selectedImageIndex,
onDismiss: { showImageViewer = false } 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 { if let fileURL = downloadedFileURL {
ShareSheet(activityItems: [fileURL]) ShareSheet(activityItems: [fileURL])
} }
@@ -105,6 +111,9 @@ struct DocumentDetailView: View {
// MARK: - Download File // 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) { private func downloadFile(document: Document) {
guard let fileUrl = document.fileUrl else { guard let fileUrl = document.fileUrl else {
downloadError = "No file URL available" downloadError = "No file URL available"
@@ -177,7 +186,6 @@ struct DocumentDetailView: View {
await MainActor.run { await MainActor.run {
downloadedFileURL = destinationURL downloadedFileURL = destinationURL
isDownloading = false isDownloading = false
showShareSheet = true
} }
} catch { } catch {

View File

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

View File

@@ -28,7 +28,7 @@ struct DocumentsWarrantiesView: View {
if showActiveOnly && doc.isActive != true { if showActiveOnly && doc.isActive != true {
return false return false
} }
if let category = selectedCategory, doc.category != category { if let category = selectedCategory, doc.category?.lowercased() != category.lowercased() {
return false return false
} }
return true return true
@@ -38,17 +38,13 @@ struct DocumentsWarrantiesView: View {
var documents: [Document] { var documents: [Document] {
documentViewModel.documents.filter { doc in documentViewModel.documents.filter { doc in
guard doc.documentType != "warranty" else { return false } 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 false
} }
return true return true
} }
} }
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents")
}
var body: some View { var body: some View {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
@@ -209,6 +205,8 @@ struct DocumentsWarrantiesView: View {
.navigationDestination(isPresented: $navigateToPushDocument) { .navigationDestination(isPresented: $navigateToPushDocument) {
if let documentId = pushTargetDocumentId { if let documentId = pushTargetDocumentId {
DocumentDetailView(documentId: documentId) DocumentDetailView(documentId: documentId)
} else {
Color.clear.onAppear { navigateToPushDocument = false }
} }
} }
} }
@@ -226,7 +224,13 @@ struct DocumentsWarrantiesView: View {
} }
private func navigateToDocumentFromPush(documentId: Int) { private func navigateToDocumentFromPush(documentId: Int) {
// 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 selectedTab = .warranties
}
pushTargetDocumentId = Int32(documentId) pushTargetDocumentId = Int32(documentId)
navigateToPushDocument = true navigateToPushDocument = true
PushNotificationManager.shared.pendingNavigationDocumentId = nil PushNotificationManager.shared.pendingNavigationDocumentId = nil

View File

@@ -586,6 +586,7 @@ enum L10n {
// MARK: - Common // MARK: - Common
enum Common { enum Common {
static var save: String { String(localized: "common_save") } 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 cancel: String { String(localized: "common_cancel") }
static var delete: String { String(localized: "common_delete") } static var delete: String { String(localized: "common_delete") }
static var edit: String { String(localized: "common_edit") } static var edit: String { String(localized: "common_edit") }

View File

@@ -44,7 +44,7 @@ struct ViewStateHandler<Content: View>: View {
content content
} }
} }
.onChange(of: error) { errorMessage in .onChange(of: error) { _, errorMessage in
if let errorMessage = errorMessage, !errorMessage.isEmpty { if let errorMessage = errorMessage, !errorMessage.isEmpty {
errorAlert = ErrorAlertInfo(message: errorMessage) errorAlert = ErrorAlertInfo(message: errorMessage)
} }
@@ -93,7 +93,7 @@ private struct ErrorHandlerModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.onChange(of: error) { errorMessage in .onChange(of: error) { _, errorMessage in
if let errorMessage = errorMessage, !errorMessage.isEmpty { if let errorMessage = errorMessage, !errorMessage.isEmpty {
errorAlert = ErrorAlertInfo(message: errorMessage) errorAlert = ErrorAlertInfo(message: errorMessage)
} }

View File

@@ -8,6 +8,12 @@ import WidgetKit
final class WidgetActionProcessor { final class WidgetActionProcessor {
static let shared = 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() {} private init() {}
/// Check if there are pending widget actions to process /// Check if there are pending widget actions to process
@@ -65,23 +71,38 @@ final class WidgetActionProcessor {
if result is ApiResultSuccess<TaskCompletionResponse> { if result is ApiResultSuccess<TaskCompletionResponse> {
print("WidgetActionProcessor: Task \(taskId) completed successfully") print("WidgetActionProcessor: Task \(taskId) completed successfully")
// Remove the processed action // Remove the processed action and clear pending state
WidgetDataManager.shared.removeAction(action) WidgetDataManager.shared.removeAction(action)
// Clear pending state for this task
WidgetDataManager.shared.clearPendingState(forTaskId: taskId) WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
retryCounts.removeValue(forKey: taskId)
// Refresh tasks to update UI // Refresh tasks to update UI
await refreshTasks() await refreshTasks()
} else if let error = ApiResultBridge.error(from: result) { } else if let error = ApiResultBridge.error(from: result) {
print("WidgetActionProcessor: Failed to complete task \(taskId): \(error.message)") print("WidgetActionProcessor: Failed to complete task \(taskId): \(error.message)")
// Remove action to avoid infinite retries handleRetryOrDiscard(taskId: taskId, action: action, reason: error.message)
WidgetDataManager.shared.removeAction(action)
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
} }
} catch { } catch {
print("WidgetActionProcessor: Error completing task \(taskId): \(error)") 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.removeAction(action)
WidgetDataManager.shared.clearPendingState(forTaskId: taskId) 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 { fileQueue.async {
// Load actions within the serial queue to avoid race conditions // Load actions within the serial queue to avoid race conditions
var actions: [WidgetAction] var actions: [WidgetAction]
if FileManager.default.fileExists(atPath: fileURL.path), if FileManager.default.fileExists(atPath: fileURL.path) {
let data = try? Data(contentsOf: fileURL), do {
let decoded = try? JSONDecoder().decode([WidgetAction].self, from: data) { let data = try Data(contentsOf: fileURL)
actions = decoded actions = try JSONDecoder().decode([WidgetAction].self, from: data)
} catch {
print("WidgetDataManager: Failed to decode pending actions: \(error)")
actions = []
}
} else { } else {
actions = [] actions = []
} }

View File

@@ -42,19 +42,6 @@
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <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> </dict>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <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" : { "000000" : {
"comment" : "A placeholder text for a 6-digit code field.", "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.", "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 "isCommentAutoGenerated" : true
}, },
"Are you sure you want to cancel this task? This 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 the task details view. It confirms that the user intends to cancel the task and warns them that the action cannot be undone.", "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 "isCommentAutoGenerated" : true
}, },
"Are you sure you want to remove %@ from this residence?" : { "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.", "comment" : "A description of how long the verification code is valid for.",
"isCommentAutoGenerated" : true "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" : { "common_back" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {

View File

@@ -117,7 +117,9 @@ extension AppleSignInManager: ASAuthorizationControllerDelegate {
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding { extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { 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 // Get the key window for presentation
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first(where: { $0.isKeyWindow }) else { let window = scene.windows.first(where: { $0.isKeyWindow }) else {

View File

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

View File

@@ -1,59 +1,75 @@
import SwiftUI import SwiftUI
struct MainTabView: View { struct MainTabView: View {
enum Tab: Hashable {
case residences
case tasks
case contractors
case documents
}
@EnvironmentObject private var themeManager: ThemeManager @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 authManager = AuthenticationManager.shared
@ObservedObject private var pushManager = PushNotificationManager.shared @ObservedObject private var pushManager = PushNotificationManager.shared
var refreshID: UUID var refreshID: UUID
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
NavigationStack { NavigationStack(path: $residencesPath) {
ResidencesListView() ResidencesListView()
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Residences", image: "tab_view_house") Label("Residences", image: "tab_view_house")
} }
.tag(0) .tag(Tab.residences)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
NavigationStack { NavigationStack(path: $tasksPath) {
AllTasksView() AllTasksView()
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Tasks", systemImage: "checklist") Label("Tasks", systemImage: "checklist")
} }
.tag(1) .tag(Tab.tasks)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
NavigationStack { NavigationStack(path: $contractorsPath) {
ContractorsListView() ContractorsListView()
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Contractors", systemImage: "wrench.and.screwdriver.fill") Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
} }
.tag(2) .tag(Tab.contractors)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
NavigationStack { NavigationStack(path: $documentsPath) {
DocumentsWarrantiesView(residenceId: nil) DocumentsWarrantiesView(residenceId: nil)
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Docs", systemImage: "doc.text.fill") Label("Docs", systemImage: "doc.text.fill")
} }
.tag(3) .tag(Tab.documents)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
} }
.tabViewStyle(.sidebarAdaptable)
.tint(Color.appPrimary) .tint(Color.appPrimary)
.onChange(of: authManager.isAuthenticated) { _, _ in .onChange(of: authManager.isAuthenticated) { _, _ in
selectedTab = 0 selectedTab = .residences
} }
.onAppear { .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 // Configure tab bar appearance
let appearance = UITabBarAppearance() let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground() appearance.configureWithOpaqueBackground()
@@ -61,18 +77,18 @@ struct MainTabView: View {
// Use theme-aware colors // Use theme-aware colors
appearance.backgroundColor = UIColor(Color.appBackgroundSecondary) 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.iconColor = UIColor(Color.appPrimary)
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [ appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor(Color.appPrimary), .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.iconColor = UIColor(Color.appTextSecondary)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [ appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: UIColor(Color.appTextSecondary), .foregroundColor: UIColor(Color.appTextSecondary),
.font: UIFont.systemFont(ofSize: 10, weight: .medium) .font: UIFont.preferredFont(forTextStyle: .caption2)
] ]
UITabBar.appearance().standardAppearance = appearance UITabBar.appearance().standardAppearance = appearance
@@ -80,27 +96,27 @@ struct MainTabView: View {
// Handle pending navigation from push notification // Handle pending navigation from push notification
if pushManager.pendingNavigationTaskId != nil { if pushManager.pendingNavigationTaskId != nil {
selectedTab = 1 selectedTab = .tasks
} else if pushManager.pendingNavigationDocumentId != nil { } else if pushManager.pendingNavigationDocumentId != nil {
selectedTab = 3 selectedTab = .documents
} else if pushManager.pendingNavigationResidenceId != nil { } else if pushManager.pendingNavigationResidenceId != nil {
selectedTab = 0 selectedTab = .residences
} }
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
selectedTab = 1 selectedTab = .tasks
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
selectedTab = 1 selectedTab = .tasks
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { _ in
selectedTab = 0 selectedTab = .residences
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { _ in
selectedTab = 3 selectedTab = .documents
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in .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) { private func goBack(to step: OnboardingStep) {
withAnimation(.easeInOut(duration: 0.3)) {
isNavigatingBack = true isNavigatingBack = true
withAnimation(.easeInOut(duration: 0.3)) {
onboardingState.currentStep = step onboardingState.currentStep = step
} } completion: {
// Reset after animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
isNavigatingBack = false isNavigatingBack = false
} }
} }

View File

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

View File

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

View File

@@ -333,7 +333,12 @@ struct OnboardingSubscriptionContent: View {
await MainActor.run { await MainActor.run {
isLoading = false isLoading = false
if transaction != nil { if transaction != nil {
// Check if backend verification failed (purchase valid but pending server confirmation)
if let backendError = storeKit.purchaseError {
purchaseError = backendError
} else {
onSubscribe() onSubscribe()
}
} else { } else {
purchaseError = "Purchase was cancelled. You can continue with Free or try again." 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 @Environment(\.dismiss) var dismiss
var body: some View { var body: some View {
NavigationStack {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
@@ -187,7 +186,6 @@ struct ForgotPasswordView: View {
} }
} }
} }
}
// MARK: - Organic Form Card Background // MARK: - Organic Form Card Background

View File

@@ -11,6 +11,7 @@ struct PasswordResetFlow: View {
} }
var body: some View { var body: some View {
NavigationStack {
Group { Group {
switch viewModel.currentStep { switch viewModel.currentStep {
case .requestCode: case .requestCode:
@@ -24,6 +25,7 @@ struct PasswordResetFlow: View {
} }
} }
.animation(.easeInOut, value: viewModel.currentStep) .animation(.easeInOut, value: viewModel.currentStep)
}
.onAppear { .onAppear {
// Set up callback for auto-login success // Set up callback for auto-login success
// Capture dismiss and onLoginSuccess directly to avoid holding the entire view struct // 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 { var body: some View {
NavigationStack {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
@@ -328,7 +327,6 @@ struct ResetPasswordView: View {
} }
} }
} }
}
// MARK: - Requirement Row // MARK: - Requirement Row

View File

@@ -6,7 +6,6 @@ struct VerifyResetCodeView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
var body: some View { var body: some View {
NavigationStack {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
@@ -231,7 +230,6 @@ struct VerifyResetCodeView: View {
} }
} }
} }
}
// MARK: - Background // MARK: - Background

View File

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

View File

@@ -6,6 +6,10 @@ import ComposeApp
@MainActor @MainActor
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { 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( func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -45,12 +49,19 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
// Clear badge when app becomes active // Clear badge when app becomes active
PushNotificationManager.shared.clearBadge() 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 // This ensures we have the latest subscription state if it changed while app was in background
let now = Date()
if let lastRefresh = Self.lastSubscriptionRefresh,
now.timeIntervalSince(lastRefresh) < Self.subscriptionRefreshInterval {
// Skip refreshed recently
} else {
Self.lastSubscriptionRefresh = now
Task { Task {
await StoreKitManager.shared.refreshSubscriptionStatus() await StoreKitManager.shared.refreshSubscriptionStatus()
} }
} }
}
// MARK: - Remote Notifications // MARK: - Remote Notifications

View File

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

View File

@@ -1,6 +1,9 @@
import SwiftUI import SwiftUI
import ComposeApp 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 { struct ManageUsersView: View {
let residenceId: Int32 let residenceId: Int32
let residenceName: String let residenceName: String
@@ -36,7 +39,6 @@ struct ManageUsersView: View {
if isPrimaryOwner { if isPrimaryOwner {
ShareCodeCard( ShareCodeCard(
shareCode: shareCode, shareCode: shareCode,
residenceName: residenceName,
isGeneratingCode: isGeneratingCode, isGeneratingCode: isGeneratingCode,
isGeneratingPackage: sharingManager.isGeneratingPackage, isGeneratingPackage: sharingManager.isGeneratingPackage,
onGenerateCode: generateShareCode, onGenerateCode: generateShareCode,
@@ -81,9 +83,9 @@ struct ManageUsersView: View {
} }
} }
.onAppear { .onAppear {
// Clear share code on appear so it's always blank
shareCode = nil shareCode = nil
loadUsers() loadUsers()
loadShareCode()
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { shareFileURL != nil }, get: { shareFileURL != nil },

View File

@@ -19,7 +19,6 @@ struct ResidenceDetailView: View {
@State private var showAddTask = false @State private var showAddTask = false
@State private var showEditResidence = false @State private var showEditResidence = false
@State private var showEditTask = false
@State private var showManageUsers = false @State private var showManageUsers = false
@State private var selectedTaskForEdit: TaskResponse? @State private var selectedTaskForEdit: TaskResponse?
@State private var selectedTaskForComplete: TaskResponse? @State private var selectedTaskForComplete: TaskResponse?
@@ -107,10 +106,13 @@ struct ResidenceDetailView: View {
EditResidenceView(residence: residence, isPresented: $showEditResidence) EditResidenceView(residence: residence, isPresented: $showEditResidence)
} }
} }
.sheet(isPresented: $showEditTask) { .sheet(item: $selectedTaskForEdit, onDismiss: {
if let task = selectedTaskForEdit { loadResidenceTasks(forceRefresh: true)
EditTaskView(task: task, isPresented: $showEditTask) }) { task in
} EditTaskView(task: task, isPresented: Binding(
get: { selectedTaskForEdit != nil },
set: { if !$0 { selectedTaskForEdit = nil } }
))
} }
.sheet(item: $selectedTaskForComplete, onDismiss: { .sheet(item: $selectedTaskForComplete, onDismiss: {
if let task = pendingCompletedTask { if let task = pendingCompletedTask {
@@ -176,31 +178,26 @@ struct ResidenceDetailView: View {
} }
// MARK: onChange & lifecycle // MARK: onChange & lifecycle
.onChange(of: viewModel.reportMessage) { message in .onChange(of: viewModel.reportMessage) { _, message in
if message != nil { if message != nil {
showReportAlert = true showReportAlert = true
} }
} }
.onChange(of: viewModel.selectedResidence) { residence in .onChange(of: viewModel.selectedResidence) { _, residence in
if residence != nil { if residence != nil {
hasAppeared = true hasAppeared = true
} }
} }
.onChange(of: showAddTask) { isShowing in .onChange(of: showAddTask) { _, isShowing in
if !isShowing { if !isShowing {
loadResidenceTasks(forceRefresh: true) loadResidenceTasks(forceRefresh: true)
} }
} }
.onChange(of: showEditResidence) { isShowing in .onChange(of: showEditResidence) { _, isShowing in
if !isShowing { if !isShowing {
loadResidenceData() loadResidenceData()
} }
} }
.onChange(of: showEditTask) { isShowing in
if !isShowing {
loadResidenceTasks(forceRefresh: true)
}
}
.onAppear { .onAppear {
loadResidenceData() loadResidenceData()
} }
@@ -252,7 +249,6 @@ private extension ResidenceDetailView {
tasksResponse: tasksResponse, tasksResponse: tasksResponse,
taskViewModel: taskViewModel, taskViewModel: taskViewModel,
selectedTaskForEdit: $selectedTaskForEdit, selectedTaskForEdit: $selectedTaskForEdit,
showEditTask: $showEditTask,
selectedTaskForComplete: $selectedTaskForComplete, selectedTaskForComplete: $selectedTaskForComplete,
selectedTaskForArchive: $selectedTaskForArchive, selectedTaskForArchive: $selectedTaskForArchive,
showArchiveConfirmation: $showArchiveConfirmation, 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 // MARK: - Toolbars
private extension ResidenceDetailView { private extension ResidenceDetailView {
@@ -466,6 +451,9 @@ 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() { func deleteResidence() {
guard TokenStorage.shared.getToken() != nil else { return } guard TokenStorage.shared.getToken() != nil else { return }
@@ -537,7 +525,6 @@ private struct TasksSectionContainer: View {
@ObservedObject var taskViewModel: TaskViewModel @ObservedObject var taskViewModel: TaskViewModel
@Binding var selectedTaskForEdit: TaskResponse? @Binding var selectedTaskForEdit: TaskResponse?
@Binding var showEditTask: Bool
@Binding var selectedTaskForComplete: TaskResponse? @Binding var selectedTaskForComplete: TaskResponse?
@Binding var selectedTaskForArchive: TaskResponse? @Binding var selectedTaskForArchive: TaskResponse?
@Binding var showArchiveConfirmation: Bool @Binding var showArchiveConfirmation: Bool
@@ -556,7 +543,6 @@ private struct TasksSectionContainer: View {
tasksResponse: tasksResponse, tasksResponse: tasksResponse,
onEditTask: { task in onEditTask: { task in
selectedTaskForEdit = task selectedTaskForEdit = task
showEditTask = true
}, },
onCancelTask: { task in onCancelTask: { task in
selectedTaskForCancel = task selectedTaskForCancel = task

View File

@@ -288,11 +288,6 @@ class ResidenceViewModel: ObservableObject {
errorMessage = nil 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) { func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil

View File

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

View File

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

View File

@@ -190,7 +190,7 @@ struct RootView: View {
// Show main app // Show main app
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
MainTabView(refreshID: refreshID) MainTabView(refreshID: refreshID)
.onChange(of: themeManager.currentTheme) { _ in .onChange(of: themeManager.currentTheme) { _, _ in
refreshID = UUID() refreshID = UUID()
} }
Color.clear 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 { extension Date {
/// Formats date as "MMM d, yyyy" (e.g., "Jan 15, 2024") /// Formats date as "MMM d, yyyy" (e.g., "Jan 15, 2024")
func formatted() -> String { func formattedMedium() -> String {
DateFormatters.shared.mediumDate.string(from: self) 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") /// Converts API date string to formatted display string (e.g., "Jan 2, 2025")
func toFormattedDate() -> String { func toFormattedDate() -> String {
guard let date = self.toDate() else { return self } guard let date = self.toDate() else { return self }
return date.formatted() return date.formattedMedium()
} }
/// Checks if date string represents an overdue date /// 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 // MARK: - Double Extensions for Currency and Number Formatting
extension Double { extension Double {
/// Formats as currency (e.g., "$1,234.56") /// Formats as currency (e.g., "$1,234.56")
func toCurrency() -> String { func toCurrency() -> String {
let formatter = NumberFormatter() let formatter = CachedFormatters.currency
formatter.numberStyle = .currency
formatter.currencyCode = "USD" formatter.currencyCode = "USD"
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)" return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
} }
/// Formats as currency with currency symbol (e.g., "$1,234.56") /// Formats as currency with currency symbol (e.g., "$1,234.56")
func toCurrencyString(currencyCode: String = "USD") -> String { func toCurrencyString(currencyCode: String = "USD") -> String {
let formatter = NumberFormatter() let formatter = CachedFormatters.currency
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode formatter.currencyCode = currencyCode
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)" return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
} }
/// Formats with comma separators (e.g., "1,234.56") /// Formats with comma separators (e.g., "1,234.56")
func toDecimalString(fractionDigits: Int = 2) -> String { func toDecimalString(fractionDigits: Int = 2) -> String {
let formatter = NumberFormatter() let formatter = CachedFormatters.decimal
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = fractionDigits formatter.minimumFractionDigits = fractionDigits
formatter.maximumFractionDigits = fractionDigits formatter.maximumFractionDigits = fractionDigits
return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self) 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%") /// Formats as percentage (e.g., "45.5%")
func toPercentage(fractionDigits: Int = 1) -> String { func toPercentage(fractionDigits: Int = 1) -> String {
let formatter = NumberFormatter() let formatter = CachedFormatters.percent
formatter.numberStyle = .percent
formatter.minimumFractionDigits = fractionDigits formatter.minimumFractionDigits = fractionDigits
formatter.maximumFractionDigits = fractionDigits formatter.maximumFractionDigits = fractionDigits
return formatter.string(from: NSNumber(value: self / 100)) ?? "\(self)%" return formatter.string(from: NSNumber(value: self / 100)) ?? "\(self)%"
@@ -78,8 +100,9 @@ extension Double {
extension Int { extension Int {
/// Formats with comma separators (e.g., "1,234") /// Formats with comma separators (e.g., "1,234")
func toFormattedString() -> String { func toFormattedString() -> String {
let formatter = NumberFormatter() let formatter = CachedFormatters.decimal
formatter.numberStyle = .decimal formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 0
return formatter.string(from: NSNumber(value: self)) ?? "\(self)" return formatter.string(from: NSNumber(value: self)) ?? "\(self)"
} }

View File

@@ -36,7 +36,7 @@ extension String {
/// Validates phone number (basic check) /// Validates phone number (basic check)
var isValidPhone: Bool { var isValidPhone: Bool {
let phoneRegex = "^[0-9+\\-\\(\\)\\s]{10,}$" let phoneRegex = "^(?=.*[0-9])[0-9+\\-\\(\\)\\s]{10,}$"
let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex) let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex)
return phonePredicate.evaluate(with: self) 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 // MARK: - Metadata Pill Styles
struct MetadataPillStyle: ViewModifier { struct MetadataPillStyle: ViewModifier {

View File

@@ -6,11 +6,8 @@ struct FeatureComparisonView: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared @StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
@State private var showUpgradePrompt = false @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 /// Whether the user is already subscribed from a non-iOS platform
private var isSubscribedOnOtherPlatform: Bool { private var isSubscribedOnOtherPlatform: Bool {
@@ -124,11 +121,10 @@ struct FeatureComparisonView: View {
ForEach(storeKit.products, id: \.id) { product in ForEach(storeKit.products, id: \.id) { product in
SubscriptionButton( SubscriptionButton(
product: product, product: product,
isSelected: selectedProduct?.id == product.id, isSelected: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: isProcessing, isProcessing: purchaseHelper.isProcessing,
onSelect: { onSelect: {
selectedProduct = product purchaseHelper.handlePurchase(product)
handlePurchase(product)
} }
) )
} }
@@ -151,7 +147,7 @@ struct FeatureComparisonView: View {
} }
// Error Message // Error Message
if let error = errorMessage { if let error = purchaseHelper.errorMessage {
HStack { HStack {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
@@ -168,7 +164,7 @@ struct FeatureComparisonView: View {
// Restore Purchases // Restore Purchases
if !isSubscribedOnOtherPlatform { if !isSubscribedOnOtherPlatform {
Button(action: { Button(action: {
handleRestore() purchaseHelper.handleRestore()
}) { }) {
Text("Restore Purchases") Text("Restore Purchases")
.font(.caption) .font(.caption)
@@ -187,7 +183,7 @@ struct FeatureComparisonView: View {
} }
} }
} }
.alert("Subscription Active", isPresented: $showSuccessAlert) { .alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") { Button("Done") {
isPresented = false 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 // MARK: - Subscription Button

View File

@@ -94,6 +94,7 @@ class StoreKitManager: ObservableObject {
print("✅ StoreKit: Purchase successful for \(product.id)") print("✅ StoreKit: Purchase successful for \(product.id)")
} catch { } catch {
print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)") 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 return transaction

View File

@@ -32,8 +32,19 @@ class SubscriptionCacheWrapper: ObservableObject {
if let subscription = currentSubscription, if let subscription = currentSubscription,
let expiresAt = subscription.expiresAt, let expiresAt = subscription.expiresAt,
!expiresAt.isEmpty { !expiresAt.isEmpty {
// 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" 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. // Fallback to local StoreKit entitlements.
return StoreKitManager.shared.purchasedProductIDs.isEmpty ? "free" : "pro" return StoreKitManager.shared.purchasedProductIDs.isEmpty ? "free" : "pro"

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 let icon: String
@State private var showFeatureComparison = false @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 @State private var isAnimating = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared @StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
private var triggerData: UpgradeTriggerData? { private var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey] subscriptionCache.upgradeTriggers[triggerKey]
@@ -155,11 +152,10 @@ struct UpgradeFeatureView: View {
ForEach(storeKit.products, id: \.id) { product in ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton( SubscriptionProductButton(
product: product, product: product,
isSelected: selectedProduct?.id == product.id, isSelected: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: isProcessing, isProcessing: purchaseHelper.isProcessing,
onSelect: { onSelect: {
selectedProduct = product purchaseHelper.handlePurchase(product)
handlePurchase(product)
} }
) )
} }
@@ -184,7 +180,7 @@ struct UpgradeFeatureView: View {
} }
// Error Message // Error Message
if let error = errorMessage { if let error = purchaseHelper.errorMessage {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
@@ -211,7 +207,7 @@ struct UpgradeFeatureView: View {
if !isSubscribedOnOtherPlatform { if !isSubscribedOnOtherPlatform {
Button(action: { Button(action: {
handleRestore() purchaseHelper.handleRestore()
}) { }) {
Text("Restore Purchases") Text("Restore Purchases")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
@@ -227,7 +223,7 @@ struct UpgradeFeatureView: View {
.sheet(isPresented: $showFeatureComparison) { .sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison) FeatureComparisonView(isPresented: $showFeatureComparison)
} }
.alert("Subscription Active", isPresented: $showSuccessAlert) { .alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") { } Button("Done") { }
} message: { } message: {
Text("You now have full access to all Pro features!") 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 // MARK: - Organic Feature Row

View File

@@ -124,11 +124,8 @@ struct UpgradePromptView: View {
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared @StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
@State private var showFeatureComparison = false @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 @State private var isAnimating = false
var triggerData: UpgradeTriggerData? { var triggerData: UpgradeTriggerData? {
@@ -263,11 +260,10 @@ struct UpgradePromptView: View {
ForEach(storeKit.products, id: \.id) { product in ForEach(storeKit.products, id: \.id) { product in
OrganicSubscriptionButton( OrganicSubscriptionButton(
product: product, product: product,
isSelected: selectedProduct?.id == product.id, isSelected: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: isProcessing, isProcessing: purchaseHelper.isProcessing,
onSelect: { onSelect: {
selectedProduct = product purchaseHelper.handlePurchase(product)
handlePurchase(product)
} }
) )
} }
@@ -292,7 +288,7 @@ struct UpgradePromptView: View {
} }
// Error Message // Error Message
if let error = errorMessage { if let error = purchaseHelper.errorMessage {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
@@ -319,7 +315,7 @@ struct UpgradePromptView: View {
if !isSubscribedOnOtherPlatform { if !isSubscribedOnOtherPlatform {
Button(action: { Button(action: {
handleRestore() purchaseHelper.handleRestore()
}) { }) {
Text("Restore Purchases") Text("Restore Purchases")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
@@ -347,7 +343,7 @@ struct UpgradePromptView: View {
.sheet(isPresented: $showFeatureComparison) { .sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison) FeatureComparisonView(isPresented: $showFeatureComparison)
} }
.alert("Subscription Active", isPresented: $showSuccessAlert) { .alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") { Button("Done") {
isPresented = false 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 // MARK: - Organic Feature Row

View File

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

View File

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

View File

@@ -1,11 +1,36 @@
import SwiftUI import SwiftUI
struct StatView: View { struct StatView: View {
let icon: String enum IconType {
case system(String)
case asset(String)
}
let icon: IconType
let value: String let value: String
let label: String let label: String
var color: Color = Color.appPrimary 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 { var body: some View {
VStack(spacing: OrganicSpacing.compact) { VStack(spacing: OrganicSpacing.compact) {
ZStack { ZStack {
@@ -13,15 +38,16 @@ struct StatView: View {
.fill(color.opacity(0.1)) .fill(color.opacity(0.1))
.frame(width: 52, height: 52) .frame(width: 52, height: 52)
if icon == "house_outline" { switch icon {
Image("house_outline") case .asset(let name):
Image(name)
.renderingMode(.template) .renderingMode(.template)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.foregroundColor(color) .foregroundColor(color)
} else { case .system(let name):
Image(systemName: icon) Image(systemName: name)
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
.foregroundColor(color) .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 // MARK: - Primary Badge
private struct PrimaryBadgeView: View { private struct PrimaryBadgeView: View {

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import SwiftUI import SwiftUI
import ComposeApp import ComposeApp
// TODO: (P5) Each action button that performs an API call creates its own @StateObject TaskViewModel instance. // Action buttons accept a shared TaskViewModel from the parent view to avoid redundant instances.
// This is potentially wasteful consider accepting a shared TaskViewModel from the parent view instead.
// MARK: - Edit Task Button // MARK: - Edit Task Button
struct EditTaskButton: View { struct EditTaskButton: View {
@@ -29,7 +28,7 @@ struct CancelTaskButton: View {
let onCompletion: () -> Void let onCompletion: () -> Void
let onError: (String) -> Void let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel() let viewModel: TaskViewModel
@State private var showConfirmation = false @State private var showConfirmation = false
var body: some View { var body: some View {
@@ -54,7 +53,7 @@ struct CancelTaskButton: View {
} }
} }
} message: { } 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 onCompletion: () -> Void
let onError: (String) -> Void let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel() let viewModel: TaskViewModel
var body: some View { var body: some View {
Button(action: { Button(action: {
@@ -92,7 +91,7 @@ struct MarkInProgressButton: View {
let onCompletion: () -> Void let onCompletion: () -> Void
let onError: (String) -> Void let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel() let viewModel: TaskViewModel
var body: some View { var body: some View {
Button(action: { Button(action: {
@@ -148,7 +147,7 @@ struct ArchiveTaskButton: View {
let onCompletion: () -> Void let onCompletion: () -> Void
let onError: (String) -> Void let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel() let viewModel: TaskViewModel
@State private var showConfirmation = false @State private var showConfirmation = false
var body: some View { var body: some View {
@@ -184,7 +183,7 @@ struct UnarchiveTaskButton: View {
let onCompletion: () -> Void let onCompletion: () -> Void
let onError: (String) -> Void let onError: (String) -> Void
@StateObject private var viewModel = TaskViewModel() let viewModel: TaskViewModel
var body: some View { var body: some View {
Button(action: { Button(action: {

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ struct TaskSuggestionsView: View {
// Category-colored icon // Category-colored icon
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos) Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
.font(.system(size: 18)) .font(.system(size: 18))
.foregroundColor(categoryColor(for: template.categoryName)) .foregroundColor(Color.taskCategoryColor(for: template.categoryName))
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
// Task info // Task info
@@ -78,18 +78,6 @@ struct TaskSuggestionsView: View {
.naturalShadow(.medium) .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 { #Preview {

View File

@@ -106,7 +106,7 @@ struct TaskTemplatesBrowserView: View {
// Category icon // Category icon
Image(systemName: categoryIcon(for: categoryGroup.categoryName)) Image(systemName: categoryIcon(for: categoryGroup.categoryName))
.font(.system(size: 18)) .font(.system(size: 18))
.foregroundColor(categoryColor(for: categoryGroup.categoryName)) .foregroundColor(Color.taskCategoryColor(for: categoryGroup.categoryName))
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
// Category name // Category name
@@ -180,7 +180,7 @@ struct TaskTemplatesBrowserView: View {
// Icon // Icon
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos) Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundColor(categoryColor(for: template.categoryName)) .foregroundColor(Color.taskCategoryColor(for: template.categoryName))
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
// Task info // 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 { #Preview {

View File

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