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:
@@ -40,13 +40,7 @@ struct CompleteTaskIntent: AppIntent {
|
||||
func perform() async throws -> some IntentResult {
|
||||
print("CompleteTaskIntent: Starting completion for task \(taskId)")
|
||||
|
||||
// Mark task as pending completion immediately (optimistic UI)
|
||||
WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId)
|
||||
|
||||
// Reload widget immediately to update task list and stats
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
|
||||
|
||||
// Get auth token and API URL from shared container
|
||||
// Check auth BEFORE marking pending — if auth fails the task should remain visible
|
||||
guard let token = WidgetActionManager.shared.getAuthToken() else {
|
||||
print("CompleteTaskIntent: No auth token available")
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
|
||||
@@ -59,6 +53,12 @@ struct CompleteTaskIntent: AppIntent {
|
||||
return .result()
|
||||
}
|
||||
|
||||
// Mark task as pending completion (optimistic UI) only after auth is confirmed
|
||||
WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId)
|
||||
|
||||
// Reload widget immediately to update task list and stats
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
|
||||
|
||||
// Make API call to complete the task
|
||||
let success = await WidgetAPIClient.quickCompleteTask(
|
||||
taskId: taskId,
|
||||
|
||||
@@ -12,7 +12,5 @@ import SwiftUI
|
||||
struct CaseraBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
Casera()
|
||||
CaseraControl()
|
||||
CaseraLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,6 +10,28 @@ import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Date Formatting Helper
|
||||
|
||||
/// Cached formatters to avoid repeated allocation in widget rendering
|
||||
private enum WidgetDateFormatters {
|
||||
static let dateOnly: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
static let iso8601WithFractional: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
static let iso8601: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
/// Parses date strings in either yyyy-MM-dd or ISO8601 (RFC3339) format
|
||||
/// and returns a user-friendly string like "Today" or "in X days"
|
||||
private func formatWidgetDate(_ dateString: String) -> String {
|
||||
@@ -17,20 +39,15 @@ private func formatWidgetDate(_ dateString: String) -> String {
|
||||
var date: Date?
|
||||
|
||||
// Try parsing as yyyy-MM-dd first
|
||||
let dateOnlyFormatter = DateFormatter()
|
||||
dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
|
||||
date = dateOnlyFormatter.date(from: dateString)
|
||||
date = WidgetDateFormatters.dateOnly.date(from: dateString)
|
||||
|
||||
// Try parsing as ISO8601 (RFC3339) if that fails
|
||||
if date == nil {
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
date = isoFormatter.date(from: dateString)
|
||||
date = WidgetDateFormatters.iso8601WithFractional.date(from: dateString)
|
||||
|
||||
// Try without fractional seconds
|
||||
if date == nil {
|
||||
isoFormatter.formatOptions = [.withInternetDateTime]
|
||||
date = isoFormatter.date(from: dateString)
|
||||
date = WidgetDateFormatters.iso8601.date(from: dateString)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,9 +196,11 @@ struct Provider: AppIntentTimelineProvider {
|
||||
let tasks = CacheManager.getUpcomingTasks()
|
||||
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
||||
|
||||
// Update every 30 minutes (more frequent for interactive widgets)
|
||||
// Use a longer refresh interval during overnight hours (11pm-6am)
|
||||
let currentDate = Date()
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)!
|
||||
let hour = Calendar.current.component(.hour, from: currentDate)
|
||||
let refreshMinutes = (hour >= 23 || hour < 6) ? 120 : 30
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: refreshMinutes, to: currentDate)!
|
||||
let entry = SimpleEntry(
|
||||
date: currentDate,
|
||||
configuration: configuration,
|
||||
|
||||
@@ -198,16 +198,15 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
func preparePreviewOfFile(at url: URL) async throws {
|
||||
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)")
|
||||
|
||||
// Parse the .casera file
|
||||
// Parse the .casera file — single Codable pass to detect type, then decode
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
// Detect package type first
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let typeString = json["type"] as? String,
|
||||
typeString == "residence" {
|
||||
let envelope = try? decoder.decode(PackageTypeEnvelope.self, from: data)
|
||||
|
||||
if envelope?.type == "residence" {
|
||||
currentPackageType = .residence
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let residence = try decoder.decode(ResidencePreviewData.self, from: data)
|
||||
self.residenceData = residence
|
||||
print("CaseraQLPreview: Parsed residence: \(residence.residenceName)")
|
||||
@@ -218,7 +217,6 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
} else {
|
||||
currentPackageType = .contractor
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
|
||||
self.contractorData = contractor
|
||||
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
|
||||
@@ -287,6 +285,13 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Type Discriminator
|
||||
|
||||
/// Lightweight struct to detect the package type without a full parse
|
||||
private struct PackageTypeEnvelope: Decodable {
|
||||
let type: String?
|
||||
}
|
||||
|
||||
// MARK: - Data Model
|
||||
|
||||
struct ContractorPreviewData: Codable {
|
||||
|
||||
@@ -54,13 +54,17 @@ class ThumbnailProvider: QLThumbnailProvider {
|
||||
}), nil)
|
||||
}
|
||||
|
||||
/// Lightweight struct to detect the package type via Codable instead of JSONSerialization
|
||||
private struct PackageTypeEnvelope: Decodable {
|
||||
let type: String?
|
||||
}
|
||||
|
||||
/// Detects the package type by reading the "type" field from the JSON
|
||||
private func detectPackageType(at url: URL) -> PackageType {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let typeString = json["type"] as? String,
|
||||
typeString == "residence" {
|
||||
let envelope = try JSONDecoder().decode(PackageTypeEnvelope.self, from: data)
|
||||
if envelope.type == "residence" {
|
||||
return .residence
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -230,7 +230,7 @@ final class AnalyticsManager {
|
||||
var sessionReplayEnabled: Bool {
|
||||
get {
|
||||
if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
return UserDefaults.standard.bool(forKey: Self.sessionReplayKey)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ final class BackgroundTaskManager {
|
||||
}
|
||||
|
||||
/// Perform the actual data refresh
|
||||
@MainActor
|
||||
private func performDataRefresh() async -> Bool {
|
||||
// Check if user is authenticated
|
||||
guard let token = TokenStorage.shared.getToken(), !token.isEmpty else {
|
||||
|
||||
@@ -155,6 +155,7 @@ private class AuthenticatedImageLoader: ObservableObject {
|
||||
// Create request with auth header
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.timeoutInterval = 15
|
||||
request.cachePolicy = .returnCacheDataElseLoad
|
||||
|
||||
do {
|
||||
|
||||
@@ -11,7 +11,6 @@ struct ContractorDetailView: View {
|
||||
|
||||
@State private var showingEditSheet = false
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var showingShareSheet = false
|
||||
@State private var shareFileURL: URL?
|
||||
@State private var showingUpgradePrompt = false
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@@ -64,7 +63,10 @@ struct ContractorDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
.sheet(isPresented: Binding(
|
||||
get: { shareFileURL != nil },
|
||||
set: { if !$0 { shareFileURL = nil } }
|
||||
)) {
|
||||
if let url = shareFileURL {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
@@ -101,9 +103,7 @@ struct ContractorDetailView: View {
|
||||
private func deleteContractor() {
|
||||
viewModel.deleteContractor(id: contractorId) { success in
|
||||
if success {
|
||||
Task { @MainActor in
|
||||
// Small delay to allow state to settle before dismissing
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,6 @@ struct ContractorDetailView: View {
|
||||
private func shareContractor(_ contractor: Contractor) {
|
||||
if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) {
|
||||
shareFileURL = url
|
||||
showingShareSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ struct ContractorFormSheet: View {
|
||||
if viewModel.isCreating || viewModel.isUpdating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(contractor == nil ? L10n.Contractors.addButton : L10n.Common.save)
|
||||
Text(contractor == nil ? L10n.Common.add : L10n.Common.save)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,12 @@ struct ContractorFormSheet: View {
|
||||
residenceViewModel.loadMyResidences()
|
||||
loadContractorData()
|
||||
}
|
||||
.onChange(of: residenceViewModel.selectedResidence?.id) { _, _ in
|
||||
if let residence = residenceViewModel.selectedResidence,
|
||||
residence.id == selectedResidenceId {
|
||||
selectedResidenceName = residence.name
|
||||
}
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { saveContractor() }
|
||||
@@ -440,11 +446,7 @@ struct ContractorFormSheet: View {
|
||||
if let residenceId = contractor.residenceId {
|
||||
selectedResidenceId = residenceId.int32Value
|
||||
if let selectedResidenceId {
|
||||
ComposeApp.ResidenceViewModel().getResidence(id: selectedResidenceId, onResult: { result in
|
||||
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
||||
self.selectedResidenceName = success.data?.name
|
||||
}
|
||||
})
|
||||
residenceViewModel.getResidence(id: selectedResidenceId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ class ContractorViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Private Properties
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// Guards against redundant detail reloads immediately after a mutation that already
|
||||
/// set selectedContractor from its response.
|
||||
private var suppressNextDetailReload = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -28,6 +31,20 @@ class ContractorViewModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] contractors in
|
||||
self?.contractors = contractors
|
||||
// Auto-refresh selectedContractor when the list changes,
|
||||
// so detail views stay current after mutations from other ViewModels.
|
||||
// ContractorSummary and Contractor are different types, so we can't
|
||||
// copy fields directly. Instead, if selectedContractor exists and a
|
||||
// matching summary is found, reload the full detail from the API.
|
||||
if let self = self,
|
||||
let currentId = self.selectedContractor?.id,
|
||||
contractors.contains(where: { $0.id == currentId }) {
|
||||
if self.suppressNextDetailReload {
|
||||
self.suppressNextDetailReload = false
|
||||
} else {
|
||||
self.reloadSelectedContractorQuietly(id: currentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
@@ -99,10 +116,12 @@ class ContractorViewModel: ObservableObject {
|
||||
do {
|
||||
let result = try await APILayer.shared.createContractor(request: request)
|
||||
|
||||
if result is ApiResultSuccess<Contractor> {
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
self.successMessage = "Contractor added successfully"
|
||||
self.isCreating = false
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
// Update selectedContractor with the newly created contractor
|
||||
self.suppressNextDetailReload = true
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
@@ -129,10 +148,12 @@ class ContractorViewModel: ObservableObject {
|
||||
do {
|
||||
let result = try await APILayer.shared.updateContractor(id: id, request: request)
|
||||
|
||||
if result is ApiResultSuccess<Contractor> {
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
self.successMessage = "Contractor updated successfully"
|
||||
self.isUpdating = false
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
// Update selectedContractor immediately so detail views stay fresh
|
||||
self.suppressNextDetailReload = true
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
@@ -186,8 +207,10 @@ class ContractorViewModel: ObservableObject {
|
||||
do {
|
||||
let result = try await APILayer.shared.toggleFavorite(id: id)
|
||||
|
||||
if result is ApiResultSuccess<Contractor> {
|
||||
// DataManager is updated by APILayer, view updates via observation
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
// Update selectedContractor immediately so detail views stay fresh
|
||||
self.suppressNextDetailReload = true
|
||||
self.selectedContractor = success.data
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
@@ -207,4 +230,21 @@ class ContractorViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Silently reload the selected contractor detail without showing loading state.
|
||||
/// Used when the contractors list updates and we need to keep selectedContractor fresh.
|
||||
private func reloadSelectedContractorQuietly(id: Int32) {
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.getContractor(id: id, forceRefresh: true)
|
||||
if let success = result as? ApiResultSuccess<Contractor> {
|
||||
self.selectedContractor = success.data
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — this is a background refresh, not user-initiated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,10 @@ struct ContractorsListView: View {
|
||||
}
|
||||
},
|
||||
onRefresh: {
|
||||
loadContractors(forceRefresh: true)
|
||||
viewModel.loadContractors(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
loadContractors()
|
||||
|
||||
@@ -199,7 +199,7 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
||||
let errorMessage: String?
|
||||
let content: ([T]) -> Content
|
||||
let emptyContent: () -> EmptyContent
|
||||
let onRefresh: () -> Void
|
||||
let onRefresh: () async -> Void
|
||||
let onRetry: () -> Void
|
||||
|
||||
init(
|
||||
@@ -208,7 +208,7 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
||||
errorMessage: String?,
|
||||
@ViewBuilder content: @escaping ([T]) -> Content,
|
||||
@ViewBuilder emptyContent: @escaping () -> EmptyContent,
|
||||
onRefresh: @escaping () -> Void,
|
||||
onRefresh: @escaping () async -> Void,
|
||||
onRetry: @escaping () -> Void
|
||||
) {
|
||||
self.items = items
|
||||
@@ -248,10 +248,7 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await withCheckedContinuation { continuation in
|
||||
onRefresh()
|
||||
continuation.resume()
|
||||
}
|
||||
await onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -96,30 +96,28 @@ class DataManagerObservable: ObservableObject {
|
||||
// Authentication - authToken
|
||||
let authTokenTask = Task { [weak self] in
|
||||
for await token in DataManager.shared.authToken {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
let previousToken = self.authToken
|
||||
let wasAuthenticated = previousToken != nil
|
||||
self.authToken = token
|
||||
self.isAuthenticated = token != nil
|
||||
guard let self else { return }
|
||||
let previousToken = self.authToken
|
||||
let wasAuthenticated = previousToken != nil
|
||||
self.authToken = token
|
||||
self.isAuthenticated = token != nil
|
||||
|
||||
// Token rotated/account switched without explicit logout.
|
||||
if let previousToken, let token, previousToken != token {
|
||||
PushNotificationManager.shared.clearRegistrationCache()
|
||||
}
|
||||
// Token rotated/account switched without explicit logout.
|
||||
if let previousToken, let token, previousToken != token {
|
||||
PushNotificationManager.shared.clearRegistrationCache()
|
||||
}
|
||||
|
||||
// Keep widget auth in sync with token lifecycle.
|
||||
if let token {
|
||||
WidgetDataManager.shared.saveAuthToken(token)
|
||||
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
|
||||
}
|
||||
// Keep widget auth in sync with token lifecycle.
|
||||
if let token {
|
||||
WidgetDataManager.shared.saveAuthToken(token)
|
||||
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
|
||||
}
|
||||
|
||||
// Clear widget cache on logout
|
||||
if token == nil && wasAuthenticated {
|
||||
WidgetDataManager.shared.clearCache()
|
||||
WidgetDataManager.shared.clearAuthToken()
|
||||
PushNotificationManager.shared.clearRegistrationCache()
|
||||
}
|
||||
// Clear widget cache on logout
|
||||
if token == nil && wasAuthenticated {
|
||||
WidgetDataManager.shared.clearCache()
|
||||
WidgetDataManager.shared.clearAuthToken()
|
||||
PushNotificationManager.shared.clearRegistrationCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,10 +126,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Authentication - currentUser
|
||||
let currentUserTask = Task { [weak self] in
|
||||
for await user in DataManager.shared.currentUser {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.currentUser = user
|
||||
}
|
||||
guard let self else { return }
|
||||
self.currentUser = user
|
||||
}
|
||||
}
|
||||
observationTasks.append(currentUserTask)
|
||||
@@ -139,10 +135,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Theme
|
||||
let themeIdTask = Task { [weak self] in
|
||||
for await id in DataManager.shared.themeId {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.themeId = id
|
||||
}
|
||||
guard let self else { return }
|
||||
self.themeId = id
|
||||
}
|
||||
}
|
||||
observationTasks.append(themeIdTask)
|
||||
@@ -150,10 +144,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Residences
|
||||
let residencesTask = Task { [weak self] in
|
||||
for await list in DataManager.shared.residences {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.residences = list
|
||||
}
|
||||
guard let self else { return }
|
||||
self.residences = list
|
||||
}
|
||||
}
|
||||
observationTasks.append(residencesTask)
|
||||
@@ -161,10 +153,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// MyResidences
|
||||
let myResidencesTask = Task { [weak self] in
|
||||
for await response in DataManager.shared.myResidences {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.myResidences = response
|
||||
}
|
||||
guard let self else { return }
|
||||
self.myResidences = response
|
||||
}
|
||||
}
|
||||
observationTasks.append(myResidencesTask)
|
||||
@@ -172,10 +162,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// TotalSummary
|
||||
let totalSummaryTask = Task { [weak self] in
|
||||
for await summary in DataManager.shared.totalSummary {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.totalSummary = summary
|
||||
}
|
||||
guard let self else { return }
|
||||
self.totalSummary = summary
|
||||
}
|
||||
}
|
||||
observationTasks.append(totalSummaryTask)
|
||||
@@ -183,10 +171,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// ResidenceSummaries
|
||||
let residenceSummariesTask = Task { [weak self] in
|
||||
for await summaries in DataManager.shared.residenceSummaries {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.residenceSummaries = self.convertIntMap(summaries)
|
||||
}
|
||||
guard let self else { return }
|
||||
self.residenceSummaries = self.convertIntMap(summaries)
|
||||
}
|
||||
}
|
||||
observationTasks.append(residenceSummariesTask)
|
||||
@@ -194,13 +180,12 @@ class DataManagerObservable: ObservableObject {
|
||||
// AllTasks
|
||||
let allTasksTask = Task { [weak self] in
|
||||
for await tasks in DataManager.shared.allTasks {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.allTasks = tasks
|
||||
// Save to widget shared container (debounced)
|
||||
if let tasks = tasks {
|
||||
self.debouncedWidgetSave(tasks: tasks)
|
||||
}
|
||||
guard let self else { return }
|
||||
self.allTasks = tasks
|
||||
self.recomputeActiveTasks()
|
||||
// Save to widget shared container (debounced)
|
||||
if let tasks = tasks {
|
||||
self.debouncedWidgetSave(tasks: tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,10 +194,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// TasksByResidence
|
||||
let tasksByResidenceTask = Task { [weak self] in
|
||||
for await tasks in DataManager.shared.tasksByResidence {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.tasksByResidence = self.convertIntMap(tasks)
|
||||
}
|
||||
guard let self else { return }
|
||||
self.tasksByResidence = self.convertIntMap(tasks)
|
||||
}
|
||||
}
|
||||
observationTasks.append(tasksByResidenceTask)
|
||||
@@ -220,10 +203,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Documents
|
||||
let documentsTask = Task { [weak self] in
|
||||
for await docs in DataManager.shared.documents {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.documents = docs
|
||||
}
|
||||
guard let self else { return }
|
||||
self.documents = docs
|
||||
}
|
||||
}
|
||||
observationTasks.append(documentsTask)
|
||||
@@ -231,10 +212,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// DocumentsByResidence
|
||||
let documentsByResidenceTask = Task { [weak self] in
|
||||
for await docs in DataManager.shared.documentsByResidence {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.documentsByResidence = self.convertIntArrayMap(docs)
|
||||
}
|
||||
guard let self else { return }
|
||||
self.documentsByResidence = self.convertIntArrayMap(docs)
|
||||
}
|
||||
}
|
||||
observationTasks.append(documentsByResidenceTask)
|
||||
@@ -242,10 +221,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Contractors
|
||||
let contractorsTask = Task { [weak self] in
|
||||
for await list in DataManager.shared.contractors {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.contractors = list
|
||||
}
|
||||
guard let self else { return }
|
||||
self.contractors = list
|
||||
}
|
||||
}
|
||||
observationTasks.append(contractorsTask)
|
||||
@@ -253,10 +230,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Subscription
|
||||
let subscriptionTask = Task { [weak self] in
|
||||
for await sub in DataManager.shared.subscription {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.subscription = sub
|
||||
}
|
||||
guard let self else { return }
|
||||
self.subscription = sub
|
||||
}
|
||||
}
|
||||
observationTasks.append(subscriptionTask)
|
||||
@@ -264,10 +239,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// UpgradeTriggers
|
||||
let upgradeTriggersTask = Task { [weak self] in
|
||||
for await triggers in DataManager.shared.upgradeTriggers {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.upgradeTriggers = self.convertStringMap(triggers)
|
||||
}
|
||||
guard let self else { return }
|
||||
self.upgradeTriggers = self.convertStringMap(triggers)
|
||||
}
|
||||
}
|
||||
observationTasks.append(upgradeTriggersTask)
|
||||
@@ -275,10 +248,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// FeatureBenefits
|
||||
let featureBenefitsTask = Task { [weak self] in
|
||||
for await benefits in DataManager.shared.featureBenefits {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.featureBenefits = benefits
|
||||
}
|
||||
guard let self else { return }
|
||||
self.featureBenefits = benefits
|
||||
}
|
||||
}
|
||||
observationTasks.append(featureBenefitsTask)
|
||||
@@ -286,10 +257,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Promotions
|
||||
let promotionsTask = Task { [weak self] in
|
||||
for await promos in DataManager.shared.promotions {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.promotions = promos
|
||||
}
|
||||
guard let self else { return }
|
||||
self.promotions = promos
|
||||
}
|
||||
}
|
||||
observationTasks.append(promotionsTask)
|
||||
@@ -297,10 +266,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Lookups - ResidenceTypes
|
||||
let residenceTypesTask = Task { [weak self] in
|
||||
for await types in DataManager.shared.residenceTypes {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.residenceTypes = types
|
||||
}
|
||||
guard let self else { return }
|
||||
self.residenceTypes = types
|
||||
}
|
||||
}
|
||||
observationTasks.append(residenceTypesTask)
|
||||
@@ -308,10 +275,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Lookups - TaskFrequencies
|
||||
let taskFrequenciesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskFrequencies {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskFrequencies = items
|
||||
}
|
||||
guard let self else { return }
|
||||
self.taskFrequencies = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskFrequenciesTask)
|
||||
@@ -319,10 +284,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Lookups - TaskPriorities
|
||||
let taskPrioritiesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskPriorities {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskPriorities = items
|
||||
}
|
||||
guard let self else { return }
|
||||
self.taskPriorities = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskPrioritiesTask)
|
||||
@@ -330,10 +293,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Lookups - TaskCategories
|
||||
let taskCategoriesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskCategories {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskCategories = items
|
||||
}
|
||||
guard let self else { return }
|
||||
self.taskCategories = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskCategoriesTask)
|
||||
@@ -341,10 +302,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Lookups - ContractorSpecialties
|
||||
let contractorSpecialtiesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.contractorSpecialties {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.contractorSpecialties = items
|
||||
}
|
||||
guard let self else { return }
|
||||
self.contractorSpecialties = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(contractorSpecialtiesTask)
|
||||
@@ -352,10 +311,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Task Templates
|
||||
let taskTemplatesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskTemplates {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskTemplates = items
|
||||
}
|
||||
guard let self else { return }
|
||||
self.taskTemplates = items
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskTemplatesTask)
|
||||
@@ -363,10 +320,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Task Templates Grouped
|
||||
let taskTemplatesGroupedTask = Task { [weak self] in
|
||||
for await response in DataManager.shared.taskTemplatesGrouped {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskTemplatesGrouped = response
|
||||
}
|
||||
guard let self else { return }
|
||||
self.taskTemplatesGrouped = response
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskTemplatesGroupedTask)
|
||||
@@ -374,10 +329,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Metadata - isInitialized
|
||||
let isInitializedTask = Task { [weak self] in
|
||||
for await initialized in DataManager.shared.isInitialized {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.isInitialized = initialized.boolValue
|
||||
}
|
||||
guard let self else { return }
|
||||
self.isInitialized = initialized.boolValue
|
||||
}
|
||||
}
|
||||
observationTasks.append(isInitializedTask)
|
||||
@@ -385,10 +338,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Metadata - lookupsInitialized
|
||||
let lookupsInitializedTask = Task { [weak self] in
|
||||
for await initialized in DataManager.shared.lookupsInitialized {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.lookupsInitialized = initialized.boolValue
|
||||
}
|
||||
guard let self else { return }
|
||||
self.lookupsInitialized = initialized.boolValue
|
||||
}
|
||||
}
|
||||
observationTasks.append(lookupsInitializedTask)
|
||||
@@ -396,10 +347,8 @@ class DataManagerObservable: ObservableObject {
|
||||
// Metadata - lastSyncTime
|
||||
let lastSyncTimeTask = Task { [weak self] in
|
||||
for await time in DataManager.shared.lastSyncTime {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.lastSyncTime = time.int64Value
|
||||
}
|
||||
guard let self else { return }
|
||||
self.lastSyncTime = time.int64Value
|
||||
}
|
||||
}
|
||||
observationTasks.append(lastSyncTimeTask)
|
||||
@@ -516,6 +465,9 @@ class DataManagerObservable: ObservableObject {
|
||||
}
|
||||
|
||||
// MARK: - Convenience Lookup Methods
|
||||
// Note: These use O(n) linear search which is acceptable for small lookup arrays
|
||||
// (typically <20 items each). Dictionary-based lookups would add complexity
|
||||
// for negligible performance gain at this scale.
|
||||
|
||||
/// Get residence type by ID
|
||||
func getResidenceType(id: Int32?) -> ResidenceType? {
|
||||
@@ -579,9 +531,17 @@ class DataManagerObservable: ObservableObject {
|
||||
// MARK: - Task Stats (Single Source of Truth)
|
||||
// Uses API column names + shared calculateMetrics function
|
||||
|
||||
/// Active tasks (excludes completed and cancelled)
|
||||
var activeTasks: [TaskResponse] {
|
||||
guard let response = allTasks else { return [] }
|
||||
/// Active tasks (excludes completed and cancelled).
|
||||
/// Computed once when `allTasks` changes and cached to avoid
|
||||
/// redundant iteration in `totalTaskMetrics`, `taskMetrics(for:)`, etc.
|
||||
private(set) var activeTasks: [TaskResponse] = []
|
||||
|
||||
/// Recompute cached activeTasks from allTasks
|
||||
private func recomputeActiveTasks() {
|
||||
guard let response = allTasks else {
|
||||
activeTasks = []
|
||||
return
|
||||
}
|
||||
var tasks: [TaskResponse] = []
|
||||
for column in response.columns {
|
||||
let columnName = column.name.lowercased()
|
||||
@@ -590,7 +550,7 @@ class DataManagerObservable: ObservableObject {
|
||||
}
|
||||
tasks.append(contentsOf: column.tasks)
|
||||
}
|
||||
return tasks
|
||||
activeTasks = tasks
|
||||
}
|
||||
|
||||
/// Get tasks from a specific column by name
|
||||
|
||||
@@ -7,18 +7,24 @@ import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// MARK: - Dynamic Theme Resolution
|
||||
|
||||
/// Shared App Group defaults for reading the active theme.
|
||||
/// Thread-safe: UserDefaults is safe to read from any thread/actor.
|
||||
private static let _themeDefaults: UserDefaults = {
|
||||
UserDefaults(suiteName: "group.com.tt.casera.CaseraDev") ?? .standard
|
||||
}()
|
||||
|
||||
private static func themed(_ name: String) -> Color {
|
||||
// Both main app and widgets use the theme from ThemeManager
|
||||
// Theme is shared via App Group UserDefaults
|
||||
let theme = MainActor.assumeIsolated {
|
||||
ThemeManager.shared.currentTheme.rawValue
|
||||
}
|
||||
// Read theme directly from shared UserDefaults instead of going through
|
||||
// @MainActor-isolated ThemeManager.shared. This is safe to call from any
|
||||
// actor context (including widget timeline providers and background threads).
|
||||
let theme = _themeDefaults.string(forKey: "selectedTheme") ?? ThemeID.bright.rawValue
|
||||
return Color("\(theme)/\(name)", bundle: nil)
|
||||
}
|
||||
|
||||
// MARK: - Semantic Colors (Use These in UI)
|
||||
// These dynamically resolve based on ThemeManager.shared.currentTheme
|
||||
// Theme is shared between main app and widgets via App Group
|
||||
// These dynamically resolve based on the active theme stored in App Group UserDefaults.
|
||||
// Safe to call from any actor context (main app, widget extensions, background threads).
|
||||
static var appPrimary: Color { themed("Primary") }
|
||||
static var appSecondary: Color { themed("Secondary") }
|
||||
static var appAccent: Color { themed("Accent") }
|
||||
|
||||
@@ -40,6 +40,9 @@ struct DocumentsTabContent: View {
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadDocuments(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
viewModel.loadDocuments()
|
||||
|
||||
@@ -42,6 +42,9 @@ struct WarrantiesTabContent: View {
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadDocuments(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
viewModel.loadDocuments()
|
||||
|
||||
@@ -4,6 +4,10 @@ import ComposeApp
|
||||
struct WarrantyCard: View {
|
||||
let document: Document
|
||||
|
||||
var hasEndDate: Bool {
|
||||
document.daysUntilExpiration != nil
|
||||
}
|
||||
|
||||
var daysUntilExpiration: Int {
|
||||
Int(document.daysUntilExpiration ?? 0)
|
||||
}
|
||||
@@ -90,7 +94,7 @@ struct WarrantyCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
if document.isActive && daysUntilExpiration >= 0 {
|
||||
if document.isActive && hasEndDate && daysUntilExpiration >= 0 {
|
||||
Text(String(format: L10n.Documents.daysRemainingCount, daysUntilExpiration))
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(statusColor)
|
||||
|
||||
@@ -14,7 +14,6 @@ struct DocumentDetailView: View {
|
||||
@State private var downloadProgress: Double = 0
|
||||
@State private var downloadError: String?
|
||||
@State private var downloadedFileURL: URL?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -43,6 +42,8 @@ struct DocumentDetailView: View {
|
||||
.navigationDestination(isPresented: $navigateToEdit) {
|
||||
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
|
||||
EditDocumentView(document: successState.document)
|
||||
} else {
|
||||
Color.clear.onAppear { navigateToEdit = false }
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
@@ -82,7 +83,7 @@ struct DocumentDetailView: View {
|
||||
deleteSucceeded = true
|
||||
}
|
||||
}
|
||||
.onChange(of: deleteSucceeded) { succeeded in
|
||||
.onChange(of: deleteSucceeded) { _, succeeded in
|
||||
if succeeded {
|
||||
dismiss()
|
||||
}
|
||||
@@ -94,9 +95,14 @@ struct DocumentDetailView: View {
|
||||
selectedIndex: $selectedImageIndex,
|
||||
onDismiss: { showImageViewer = false }
|
||||
)
|
||||
} else {
|
||||
Color.clear.onAppear { showImageViewer = false }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
.sheet(isPresented: Binding(
|
||||
get: { downloadedFileURL != nil },
|
||||
set: { if !$0 { downloadedFileURL = nil } }
|
||||
)) {
|
||||
if let fileURL = downloadedFileURL {
|
||||
ShareSheet(activityItems: [fileURL])
|
||||
}
|
||||
@@ -105,6 +111,9 @@ struct DocumentDetailView: View {
|
||||
|
||||
// MARK: - Download File
|
||||
|
||||
// FIX_SKIPPED: LE-4 — downloadFile() is an 80-line method performing direct URLSession
|
||||
// networking inside the view. Fixing requires extracting a dedicated DownloadViewModel
|
||||
// or DocumentDownloadManager — architectural refactor deferred.
|
||||
private func downloadFile(document: Document) {
|
||||
guard let fileUrl = document.fileUrl else {
|
||||
downloadError = "No file URL available"
|
||||
@@ -177,7 +186,6 @@ struct DocumentDetailView: View {
|
||||
await MainActor.run {
|
||||
downloadedFileURL = destinationURL
|
||||
isDownloading = false
|
||||
showShareSheet = true
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
@@ -216,7 +216,7 @@ struct DocumentFormView: View {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isEditMode ? L10n.Documents.update : L10n.Common.save) {
|
||||
Button(isEditMode ? L10n.Common.save : L10n.Common.add) {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(!canSave || isProcessing)
|
||||
@@ -232,7 +232,7 @@ struct DocumentFormView: View {
|
||||
}
|
||||
))
|
||||
}
|
||||
.onChange(of: selectedPhotoItems) { items in
|
||||
.onChange(of: selectedPhotoItems) { _, items in
|
||||
Task {
|
||||
selectedImages.removeAll()
|
||||
for item in items {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct DocumentsWarrantiesView: View {
|
||||
if showActiveOnly && doc.isActive != true {
|
||||
return false
|
||||
}
|
||||
if let category = selectedCategory, doc.category != category {
|
||||
if let category = selectedCategory, doc.category?.lowercased() != category.lowercased() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -38,17 +38,13 @@ struct DocumentsWarrantiesView: View {
|
||||
var documents: [Document] {
|
||||
documentViewModel.documents.filter { doc in
|
||||
guard doc.documentType != "warranty" else { return false }
|
||||
if let docType = selectedDocType, doc.documentType != docType {
|
||||
if let docType = selectedDocType, doc.documentType.lowercased() != docType.lowercased() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowUpgrade: Bool {
|
||||
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
@@ -209,6 +205,8 @@ struct DocumentsWarrantiesView: View {
|
||||
.navigationDestination(isPresented: $navigateToPushDocument) {
|
||||
if let documentId = pushTargetDocumentId {
|
||||
DocumentDetailView(documentId: documentId)
|
||||
} else {
|
||||
Color.clear.onAppear { navigateToPushDocument = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,7 +224,13 @@ struct DocumentsWarrantiesView: View {
|
||||
}
|
||||
|
||||
private func navigateToDocumentFromPush(documentId: Int) {
|
||||
selectedTab = .warranties
|
||||
// Look up the document to determine the correct tab
|
||||
if let document = documentViewModel.documents.first(where: { $0.id?.int32Value == Int32(documentId) }) {
|
||||
selectedTab = document.documentType == "warranty" ? .warranties : .documents
|
||||
} else {
|
||||
// Default to warranties if document not found in cache
|
||||
selectedTab = .warranties
|
||||
}
|
||||
pushTargetDocumentId = Int32(documentId)
|
||||
navigateToPushDocument = true
|
||||
PushNotificationManager.shared.pendingNavigationDocumentId = nil
|
||||
|
||||
@@ -586,6 +586,7 @@ enum L10n {
|
||||
// MARK: - Common
|
||||
enum Common {
|
||||
static var save: String { String(localized: "common_save") }
|
||||
static var add: String { String(localized: "common_add") }
|
||||
static var cancel: String { String(localized: "common_cancel") }
|
||||
static var delete: String { String(localized: "common_delete") }
|
||||
static var edit: String { String(localized: "common_edit") }
|
||||
|
||||
@@ -44,7 +44,7 @@ struct ViewStateHandler<Content: View>: View {
|
||||
content
|
||||
}
|
||||
}
|
||||
.onChange(of: error) { errorMessage in
|
||||
.onChange(of: error) { _, errorMessage in
|
||||
if let errorMessage = errorMessage, !errorMessage.isEmpty {
|
||||
errorAlert = ErrorAlertInfo(message: errorMessage)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ private struct ErrorHandlerModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onChange(of: error) { errorMessage in
|
||||
.onChange(of: error) { _, errorMessage in
|
||||
if let errorMessage = errorMessage, !errorMessage.isEmpty {
|
||||
errorAlert = ErrorAlertInfo(message: errorMessage)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import WidgetKit
|
||||
final class WidgetActionProcessor {
|
||||
static let shared = WidgetActionProcessor()
|
||||
|
||||
/// Maximum number of retry attempts per action before giving up
|
||||
private static let maxRetries = 3
|
||||
|
||||
/// Tracks retry counts by action description (taskId)
|
||||
private var retryCounts: [Int: Int] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Check if there are pending widget actions to process
|
||||
@@ -65,23 +71,38 @@ final class WidgetActionProcessor {
|
||||
|
||||
if result is ApiResultSuccess<TaskCompletionResponse> {
|
||||
print("WidgetActionProcessor: Task \(taskId) completed successfully")
|
||||
// Remove the processed action
|
||||
// Remove the processed action and clear pending state
|
||||
WidgetDataManager.shared.removeAction(action)
|
||||
// Clear pending state for this task
|
||||
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
|
||||
retryCounts.removeValue(forKey: taskId)
|
||||
// Refresh tasks to update UI
|
||||
await refreshTasks()
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("WidgetActionProcessor: Failed to complete task \(taskId): \(error.message)")
|
||||
// Remove action to avoid infinite retries
|
||||
WidgetDataManager.shared.removeAction(action)
|
||||
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
|
||||
handleRetryOrDiscard(taskId: taskId, action: action, reason: error.message)
|
||||
}
|
||||
} catch {
|
||||
print("WidgetActionProcessor: Error completing task \(taskId): \(error)")
|
||||
// Remove action to avoid retries on error
|
||||
handleRetryOrDiscard(taskId: taskId, action: action, reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment retry count; discard action only after maxRetries.
|
||||
/// On failure, clear pending state so the task reappears in the widget.
|
||||
private func handleRetryOrDiscard(taskId: Int, action: WidgetDataManager.WidgetAction, reason: String) {
|
||||
let attempts = (retryCounts[taskId] ?? 0) + 1
|
||||
retryCounts[taskId] = attempts
|
||||
|
||||
if attempts >= Self.maxRetries {
|
||||
print("WidgetActionProcessor: Task \(taskId) failed after \(attempts) attempts (\(reason)). Discarding action.")
|
||||
WidgetDataManager.shared.removeAction(action)
|
||||
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
|
||||
retryCounts.removeValue(forKey: taskId)
|
||||
} else {
|
||||
print("WidgetActionProcessor: Task \(taskId) attempt \(attempts)/\(Self.maxRetries) failed (\(reason)). Keeping for retry.")
|
||||
// Clear pending state so the task is visible in the widget again,
|
||||
// but keep the action so it will be retried next time the app becomes active.
|
||||
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,10 +222,14 @@ final class WidgetDataManager {
|
||||
fileQueue.async {
|
||||
// Load actions within the serial queue to avoid race conditions
|
||||
var actions: [WidgetAction]
|
||||
if FileManager.default.fileExists(atPath: fileURL.path),
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let decoded = try? JSONDecoder().decode([WidgetAction].self, from: data) {
|
||||
actions = decoded
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
actions = try JSONDecoder().decode([WidgetAction].self, from: data)
|
||||
} catch {
|
||||
print("WidgetDataManager: Failed to decode pending actions: \(error)")
|
||||
actions = []
|
||||
}
|
||||
} else {
|
||||
actions = []
|
||||
}
|
||||
|
||||
@@ -42,19 +42,6 @@
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>127.0.0.1</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
|
||||
@@ -107,10 +107,6 @@
|
||||
},
|
||||
"$" : {
|
||||
|
||||
},
|
||||
"$%@" : {
|
||||
"comment" : "A label displaying the cost of a task. The argument is the cost of the task.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"000000" : {
|
||||
"comment" : "A placeholder text for a 6-digit code field.",
|
||||
@@ -316,8 +312,8 @@
|
||||
"comment" : "An alert message displayed when the user taps the \"Archive\" button on a task. It confirms that the user intends to archive the task and provides a hint that the task can be restored later.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Are you sure you want to cancel this task? This action cannot be undone." : {
|
||||
"comment" : "An alert message displayed when a user taps the \"Cancel Task\" button in the task details view. It confirms that the user intends to cancel the task and warns them that the action cannot be undone.",
|
||||
"Are you sure you want to cancel this task? You can undo this later." : {
|
||||
"comment" : "An alert message displayed when a user taps the \"Cancel Task\" button in a task list. It confirms that the user intends to cancel the task and provides a way to undo the action.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Are you sure you want to remove %@ from this residence?" : {
|
||||
@@ -4298,6 +4294,71 @@
|
||||
"comment" : "A description of how long the verification code is valid for.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"common_add" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Hinzufügen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Agregar"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ajouter"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aggiungi"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "追加"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "추가"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Toevoegen"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Adicionar"
|
||||
}
|
||||
},
|
||||
"zh" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "添加"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common_back" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -117,7 +117,9 @@ extension AppleSignInManager: ASAuthorizationControllerDelegate {
|
||||
|
||||
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
|
||||
nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
||||
MainActor.assumeIsolated {
|
||||
// This method is always called on the main thread by Apple's framework.
|
||||
// Use DispatchQueue.main.sync as a safe bridge instead of assumeIsolated.
|
||||
DispatchQueue.main.sync {
|
||||
// Get the key window for presentation
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
|
||||
|
||||
@@ -174,8 +174,24 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
return
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let idToken = json["id_token"] as? String else {
|
||||
let json: [String: Any]
|
||||
do {
|
||||
guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
return
|
||||
}
|
||||
json = parsed
|
||||
} catch {
|
||||
print("GoogleSignInManager: Failed to parse token response JSON: \(error)")
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
return
|
||||
}
|
||||
|
||||
guard let idToken = json["id_token"] as? String else {
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
@@ -194,12 +210,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
|
||||
/// Send Google ID token to backend for verification and authentication
|
||||
private func sendToBackend(idToken: String) async {
|
||||
let request = GoogleSignInRequest(idToken: idToken)
|
||||
let result = try? await APILayer.shared.googleSignIn(request: request)
|
||||
|
||||
guard let result else {
|
||||
let result: Any
|
||||
do {
|
||||
result = try await APILayer.shared.googleSignIn(request: request)
|
||||
} catch {
|
||||
print("GoogleSignInManager: Backend sign-in request failed: \(error)")
|
||||
resetOAuthState()
|
||||
isLoading = false
|
||||
errorMessage = "Sign in failed. Please try again."
|
||||
errorMessage = "Sign in failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
enum Tab: Hashable {
|
||||
case residences
|
||||
case tasks
|
||||
case contractors
|
||||
case documents
|
||||
}
|
||||
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@State private var selectedTab = 0
|
||||
@State private var selectedTab: Tab = .residences
|
||||
@State private var residencesPath = NavigationPath()
|
||||
@State private var tasksPath = NavigationPath()
|
||||
@State private var contractorsPath = NavigationPath()
|
||||
@State private var documentsPath = NavigationPath()
|
||||
@ObservedObject private var authManager = AuthenticationManager.shared
|
||||
@ObservedObject private var pushManager = PushNotificationManager.shared
|
||||
var refreshID: UUID
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $residencesPath) {
|
||||
ResidencesListView()
|
||||
}
|
||||
.id(refreshID)
|
||||
.tabItem {
|
||||
Label("Residences", image: "tab_view_house")
|
||||
}
|
||||
.tag(0)
|
||||
.tag(Tab.residences)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
|
||||
|
||||
NavigationStack {
|
||||
NavigationStack(path: $tasksPath) {
|
||||
AllTasksView()
|
||||
}
|
||||
.id(refreshID)
|
||||
.tabItem {
|
||||
Label("Tasks", systemImage: "checklist")
|
||||
}
|
||||
.tag(1)
|
||||
.tag(Tab.tasks)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
|
||||
|
||||
NavigationStack {
|
||||
NavigationStack(path: $contractorsPath) {
|
||||
ContractorsListView()
|
||||
}
|
||||
.id(refreshID)
|
||||
.tabItem {
|
||||
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
|
||||
}
|
||||
.tag(2)
|
||||
.tag(Tab.contractors)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
|
||||
|
||||
NavigationStack {
|
||||
NavigationStack(path: $documentsPath) {
|
||||
DocumentsWarrantiesView(residenceId: nil)
|
||||
}
|
||||
.id(refreshID)
|
||||
.tabItem {
|
||||
Label("Docs", systemImage: "doc.text.fill")
|
||||
}
|
||||
.tag(3)
|
||||
.tag(Tab.documents)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.tint(Color.appPrimary)
|
||||
.onChange(of: authManager.isAuthenticated) { _, _ in
|
||||
selectedTab = 0
|
||||
selectedTab = .residences
|
||||
}
|
||||
.onAppear {
|
||||
// FIX_SKIPPED(F-10): UITabBar.appearance() is the standard SwiftUI pattern
|
||||
// for customizing tab bar appearance. The global side effect persists but
|
||||
// there is no safe alternative without UIKit hosting.
|
||||
|
||||
// Configure tab bar appearance
|
||||
let appearance = UITabBarAppearance()
|
||||
appearance.configureWithOpaqueBackground()
|
||||
@@ -61,18 +77,18 @@ struct MainTabView: View {
|
||||
// Use theme-aware colors
|
||||
appearance.backgroundColor = UIColor(Color.appBackgroundSecondary)
|
||||
|
||||
// Selected item
|
||||
// Selected item — uses Dynamic Type caption2 style (A-2)
|
||||
appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary)
|
||||
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
|
||||
.foregroundColor: UIColor(Color.appPrimary),
|
||||
.font: UIFont.systemFont(ofSize: 10, weight: .semibold)
|
||||
.font: UIFont.preferredFont(forTextStyle: .caption2)
|
||||
]
|
||||
|
||||
// Normal item
|
||||
// Normal item — uses Dynamic Type caption2 style (A-2)
|
||||
appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary)
|
||||
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
|
||||
.foregroundColor: UIColor(Color.appTextSecondary),
|
||||
.font: UIFont.systemFont(ofSize: 10, weight: .medium)
|
||||
.font: UIFont.preferredFont(forTextStyle: .caption2)
|
||||
]
|
||||
|
||||
UITabBar.appearance().standardAppearance = appearance
|
||||
@@ -80,27 +96,27 @@ struct MainTabView: View {
|
||||
|
||||
// Handle pending navigation from push notification
|
||||
if pushManager.pendingNavigationTaskId != nil {
|
||||
selectedTab = 1
|
||||
selectedTab = .tasks
|
||||
} else if pushManager.pendingNavigationDocumentId != nil {
|
||||
selectedTab = 3
|
||||
selectedTab = .documents
|
||||
} else if pushManager.pendingNavigationResidenceId != nil {
|
||||
selectedTab = 0
|
||||
selectedTab = .residences
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
|
||||
selectedTab = 1
|
||||
selectedTab = .tasks
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
|
||||
selectedTab = 1
|
||||
selectedTab = .tasks
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { _ in
|
||||
selectedTab = 0
|
||||
selectedTab = .residences
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { _ in
|
||||
selectedTab = 3
|
||||
selectedTab = .documents
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
|
||||
selectedTab = 0
|
||||
selectedTab = .residences
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,10 @@ struct OnboardingCoordinator: View {
|
||||
}
|
||||
|
||||
private func goBack(to step: OnboardingStep) {
|
||||
isNavigatingBack = true
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isNavigatingBack = true
|
||||
onboardingState.currentStep = step
|
||||
}
|
||||
// Reset after animation completes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
} completion: {
|
||||
isNavigatingBack = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +505,7 @@ struct OnboardingFirstTaskContent: View {
|
||||
|
||||
// Format today's date as YYYY-MM-DD for the API
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let todayString = dateFormatter.string(from: Date())
|
||||
|
||||
|
||||
@@ -68,12 +68,12 @@ class OnboardingState: ObservableObject {
|
||||
pendingPostalCode = zip
|
||||
isLoadingTemplates = true
|
||||
Task {
|
||||
defer { self.isLoadingTemplates = false }
|
||||
let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip)
|
||||
if let success = result as? ApiResultSuccess<NSArray>,
|
||||
let templates = success.data as? [TaskTemplate] {
|
||||
self.regionalTemplates = templates
|
||||
}
|
||||
self.isLoadingTemplates = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +333,12 @@ struct OnboardingSubscriptionContent: View {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
if transaction != nil {
|
||||
onSubscribe()
|
||||
// Check if backend verification failed (purchase valid but pending server confirmation)
|
||||
if let backendError = storeKit.purchaseError {
|
||||
purchaseError = backendError
|
||||
} else {
|
||||
onSubscribe()
|
||||
}
|
||||
} else {
|
||||
purchaseError = "Purchase was cancelled. You can continue with Free or try again."
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ struct ForgotPasswordView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
|
||||
@@ -185,7 +184,6 @@ struct ForgotPasswordView: View {
|
||||
.onAppear {
|
||||
isEmailFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,19 +11,21 @@ struct PasswordResetFlow: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.currentStep {
|
||||
case .requestCode:
|
||||
ForgotPasswordView(viewModel: viewModel)
|
||||
case .verifyCode:
|
||||
VerifyResetCodeView(viewModel: viewModel)
|
||||
case .resetPassword, .loggingIn, .success:
|
||||
ResetPasswordView(viewModel: viewModel, onSuccess: {
|
||||
dismiss()
|
||||
})
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch viewModel.currentStep {
|
||||
case .requestCode:
|
||||
ForgotPasswordView(viewModel: viewModel)
|
||||
case .verifyCode:
|
||||
VerifyResetCodeView(viewModel: viewModel)
|
||||
case .resetPassword, .loggingIn, .success:
|
||||
ResetPasswordView(viewModel: viewModel, onSuccess: {
|
||||
dismiss()
|
||||
})
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: viewModel.currentStep)
|
||||
}
|
||||
.animation(.easeInOut, value: viewModel.currentStep)
|
||||
.onAppear {
|
||||
// Set up callback for auto-login success
|
||||
// Capture dismiss and onLoginSuccess directly to avoid holding the entire view struct
|
||||
|
||||
@@ -35,7 +35,6 @@ struct ResetPasswordView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
|
||||
@@ -326,7 +325,6 @@ struct ResetPasswordView: View {
|
||||
.onAppear {
|
||||
focusedField = .newPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ struct VerifyResetCodeView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
|
||||
@@ -229,7 +228,6 @@ struct VerifyResetCodeView: View {
|
||||
.onAppear {
|
||||
isCodeFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ class ProfileViewModel: ObservableObject {
|
||||
@Published var firstName: String = ""
|
||||
@Published var lastName: String = ""
|
||||
@Published var email: String = ""
|
||||
@Published var isEditing: Bool = false
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var isLoadingUser: Bool = true
|
||||
@Published var errorMessage: String?
|
||||
@@ -29,7 +28,7 @@ class ProfileViewModel: ObservableObject {
|
||||
DataManagerObservable.shared.$currentUser
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] user in
|
||||
guard let self, !self.isEditing else { return }
|
||||
guard let self else { return }
|
||||
if let user = user {
|
||||
self.firstName = user.firstName ?? ""
|
||||
self.lastName = user.lastName ?? ""
|
||||
|
||||
@@ -6,6 +6,10 @@ import ComposeApp
|
||||
@MainActor
|
||||
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
/// Throttle subscription refreshes to at most once every 5 minutes
|
||||
private static var lastSubscriptionRefresh: Date?
|
||||
private static let subscriptionRefreshInterval: TimeInterval = 300 // 5 minutes
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
@@ -45,10 +49,17 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
// Clear badge when app becomes active
|
||||
PushNotificationManager.shared.clearBadge()
|
||||
|
||||
// Refresh StoreKit subscription status when app comes to foreground
|
||||
// Refresh StoreKit subscription status when app comes to foreground (throttled to every 5 min)
|
||||
// This ensures we have the latest subscription state if it changed while app was in background
|
||||
Task {
|
||||
await StoreKitManager.shared.refreshSubscriptionStatus()
|
||||
let now = Date()
|
||||
if let lastRefresh = Self.lastSubscriptionRefresh,
|
||||
now.timeIntervalSince(lastRefresh) < Self.subscriptionRefreshInterval {
|
||||
// Skip — refreshed recently
|
||||
} else {
|
||||
Self.lastSubscriptionRefresh = now
|
||||
Task {
|
||||
await StoreKitManager.shared.refreshSubscriptionStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ struct JoinResidenceView: View {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||
)
|
||||
.onChange(of: shareCode) { newValue in
|
||||
.onChange(of: shareCode) { _, newValue in
|
||||
if newValue.count > 6 {
|
||||
shareCode = String(newValue.prefix(6))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
// FIX_SKIPPED: LE-2 — This view calls APILayer directly (loadUsers, loadShareCode,
|
||||
// generateShareCode, removeUser). Fixing requires extracting a dedicated ManageUsersViewModel.
|
||||
// Architectural refactor deferred — requires new ViewModel.
|
||||
struct ManageUsersView: View {
|
||||
let residenceId: Int32
|
||||
let residenceName: String
|
||||
@@ -36,7 +39,6 @@ struct ManageUsersView: View {
|
||||
if isPrimaryOwner {
|
||||
ShareCodeCard(
|
||||
shareCode: shareCode,
|
||||
residenceName: residenceName,
|
||||
isGeneratingCode: isGeneratingCode,
|
||||
isGeneratingPackage: sharingManager.isGeneratingPackage,
|
||||
onGenerateCode: generateShareCode,
|
||||
@@ -81,9 +83,9 @@ struct ManageUsersView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Clear share code on appear so it's always blank
|
||||
shareCode = nil
|
||||
loadUsers()
|
||||
loadShareCode()
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { shareFileURL != nil },
|
||||
|
||||
@@ -19,7 +19,6 @@ struct ResidenceDetailView: View {
|
||||
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditResidence = false
|
||||
@State private var showEditTask = false
|
||||
@State private var showManageUsers = false
|
||||
@State private var selectedTaskForEdit: TaskResponse?
|
||||
@State private var selectedTaskForComplete: TaskResponse?
|
||||
@@ -107,10 +106,13 @@ struct ResidenceDetailView: View {
|
||||
EditResidenceView(residence: residence, isPresented: $showEditResidence)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditTask) {
|
||||
if let task = selectedTaskForEdit {
|
||||
EditTaskView(task: task, isPresented: $showEditTask)
|
||||
}
|
||||
.sheet(item: $selectedTaskForEdit, onDismiss: {
|
||||
loadResidenceTasks(forceRefresh: true)
|
||||
}) { task in
|
||||
EditTaskView(task: task, isPresented: Binding(
|
||||
get: { selectedTaskForEdit != nil },
|
||||
set: { if !$0 { selectedTaskForEdit = nil } }
|
||||
))
|
||||
}
|
||||
.sheet(item: $selectedTaskForComplete, onDismiss: {
|
||||
if let task = pendingCompletedTask {
|
||||
@@ -176,31 +178,26 @@ struct ResidenceDetailView: View {
|
||||
}
|
||||
|
||||
// MARK: onChange & lifecycle
|
||||
.onChange(of: viewModel.reportMessage) { message in
|
||||
.onChange(of: viewModel.reportMessage) { _, message in
|
||||
if message != nil {
|
||||
showReportAlert = true
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.selectedResidence) { residence in
|
||||
.onChange(of: viewModel.selectedResidence) { _, residence in
|
||||
if residence != nil {
|
||||
hasAppeared = true
|
||||
}
|
||||
}
|
||||
.onChange(of: showAddTask) { isShowing in
|
||||
.onChange(of: showAddTask) { _, isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditResidence) { isShowing in
|
||||
.onChange(of: showEditResidence) { _, isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceData()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditTask) { isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadResidenceData()
|
||||
}
|
||||
@@ -252,7 +249,6 @@ private extension ResidenceDetailView {
|
||||
tasksResponse: tasksResponse,
|
||||
taskViewModel: taskViewModel,
|
||||
selectedTaskForEdit: $selectedTaskForEdit,
|
||||
showEditTask: $showEditTask,
|
||||
selectedTaskForComplete: $selectedTaskForComplete,
|
||||
selectedTaskForArchive: $selectedTaskForArchive,
|
||||
showArchiveConfirmation: $showArchiveConfirmation,
|
||||
@@ -335,17 +331,6 @@ private extension ResidenceDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Button Style
|
||||
|
||||
private struct OrganicCardButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbars
|
||||
|
||||
private extension ResidenceDetailView {
|
||||
@@ -466,20 +451,23 @@ private extension ResidenceDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// FIX_SKIPPED: LE-3 — deleteResidence() calls APILayer.shared.deleteResidence() directly
|
||||
// from the view. ResidenceViewModel does not expose a delete method. Fixing requires adding
|
||||
// deleteResidence() to the shared ViewModel layer — architectural refactor deferred.
|
||||
func deleteResidence() {
|
||||
guard TokenStorage.shared.getToken() != nil else { return }
|
||||
|
||||
isDeleting = true
|
||||
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.deleteResidence(
|
||||
id: Int32(Int(residenceId))
|
||||
)
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
self.isDeleting = false
|
||||
|
||||
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
dismiss()
|
||||
} else if let errorResult = ApiResultBridge.error(from: result) {
|
||||
@@ -537,7 +525,6 @@ private struct TasksSectionContainer: View {
|
||||
|
||||
@ObservedObject var taskViewModel: TaskViewModel
|
||||
@Binding var selectedTaskForEdit: TaskResponse?
|
||||
@Binding var showEditTask: Bool
|
||||
@Binding var selectedTaskForComplete: TaskResponse?
|
||||
@Binding var selectedTaskForArchive: TaskResponse?
|
||||
@Binding var showArchiveConfirmation: Bool
|
||||
@@ -556,7 +543,6 @@ private struct TasksSectionContainer: View {
|
||||
tasksResponse: tasksResponse,
|
||||
onEditTask: { task in
|
||||
selectedTaskForEdit = task
|
||||
showEditTask = true
|
||||
},
|
||||
onCancelTask: { task in
|
||||
selectedTaskForCancel = task
|
||||
|
||||
@@ -288,11 +288,6 @@ class ResidenceViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func loadResidenceContractors(residenceId: Int32) {
|
||||
// This can now be handled directly via APILayer if needed
|
||||
// or through DataManagerObservable.shared.contractors
|
||||
}
|
||||
|
||||
func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
@@ -9,10 +9,8 @@ struct ResidencesListView: View {
|
||||
@State private var showingUpgradePrompt = false
|
||||
@State private var showingSettings = false
|
||||
@State private var pushTargetResidenceId: Int32?
|
||||
@State private var showLoginCover = false
|
||||
@StateObject private var authManager = AuthenticationManager.shared
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -32,6 +30,9 @@ struct ResidencesListView: View {
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
viewModel.loadMyResidences()
|
||||
@@ -113,36 +114,19 @@ struct ResidencesListView: View {
|
||||
viewModel.loadMyResidences()
|
||||
// Also load tasks to populate summary stats
|
||||
taskViewModel.loadTasks()
|
||||
} else {
|
||||
showLoginCover = true
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
// Refresh data when app comes back from background
|
||||
if newPhase == .active && authManager.isAuthenticated {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
taskViewModel.loadTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showLoginCover) {
|
||||
LoginView(onLoginSuccess: {
|
||||
authManager.isAuthenticated = true
|
||||
showLoginCover = false
|
||||
viewModel.loadMyResidences()
|
||||
taskViewModel.loadTasks()
|
||||
})
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
.onChange(of: authManager.isAuthenticated) { isAuth in
|
||||
// P-5: Removed redundant .onChange(of: scenePhase) handler.
|
||||
// iOSApp.swift already handles foreground refresh globally, so per-view
|
||||
// scenePhase handlers fire duplicate network requests.
|
||||
.onChange(of: authManager.isAuthenticated) { _, isAuth in
|
||||
if isAuth {
|
||||
// User just logged in or registered - load their residences and tasks
|
||||
showLoginCover = false
|
||||
viewModel.loadMyResidences()
|
||||
taskViewModel.loadTasks()
|
||||
} else {
|
||||
// User logged out - clear data and show login
|
||||
// User logged out - clear data
|
||||
viewModel.myResidences = nil
|
||||
showLoginCover = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in
|
||||
@@ -150,13 +134,8 @@ struct ResidencesListView: View {
|
||||
navigateToResidenceFromPush(residenceId: residenceId)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { pushTargetResidenceId != nil },
|
||||
set: { if !$0 { pushTargetResidenceId = nil } }
|
||||
)) {
|
||||
if let residenceId = pushTargetResidenceId {
|
||||
ResidenceDetailView(residenceId: residenceId)
|
||||
}
|
||||
.navigationDestination(item: $pushTargetResidenceId) { residenceId in
|
||||
ResidenceDetailView(residenceId: residenceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,17 +232,6 @@ private struct ResidencesContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Button Style
|
||||
|
||||
private struct OrganicCardButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Empty Residences View
|
||||
|
||||
private struct OrganicEmptyResidencesView: View {
|
||||
|
||||
@@ -253,7 +253,7 @@ struct ResidenceFormView: View {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(L10n.Common.save)
|
||||
Text(isEditMode ? L10n.Common.save : L10n.Common.add)
|
||||
}
|
||||
}
|
||||
.disabled(!canSave || viewModel.isLoading)
|
||||
|
||||
@@ -190,7 +190,7 @@ struct RootView: View {
|
||||
// Show main app
|
||||
ZStack(alignment: .topLeading) {
|
||||
MainTabView(refreshID: refreshID)
|
||||
.onChange(of: themeManager.currentTheme) { _ in
|
||||
.onChange(of: themeManager.currentTheme) { _, _ in
|
||||
refreshID = UUID()
|
||||
}
|
||||
Color.clear
|
||||
|
||||
20
iosApp/iosApp/Shared/Extensions/ColorExtensions.swift
Normal file
20
iosApp/iosApp/Shared/Extensions/ColorExtensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
extension Date {
|
||||
/// Formats date as "MMM d, yyyy" (e.g., "Jan 15, 2024")
|
||||
func formatted() -> String {
|
||||
func formattedMedium() -> String {
|
||||
DateFormatters.shared.mediumDate.string(from: self)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ extension String {
|
||||
/// Converts API date string to formatted display string (e.g., "Jan 2, 2025")
|
||||
func toFormattedDate() -> String {
|
||||
guard let date = self.toDate() else { return self }
|
||||
return date.formatted()
|
||||
return date.formattedMedium()
|
||||
}
|
||||
|
||||
/// Checks if date string represents an overdue date
|
||||
|
||||
@@ -15,29 +15,52 @@ extension KotlinDouble {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cached NumberFormatters
|
||||
|
||||
/// Static cached NumberFormatter instances to avoid per-call allocation overhead.
|
||||
/// These formatters are mutated per-call for variable parameters (fractionDigits, currencyCode)
|
||||
/// and are safe because all callers run on the main thread (@MainActor ViewModels / SwiftUI views).
|
||||
private enum CachedFormatters {
|
||||
static let currency: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .currency
|
||||
f.currencyCode = "USD"
|
||||
return f
|
||||
}()
|
||||
|
||||
static let decimal: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
return f
|
||||
}()
|
||||
|
||||
static let percent: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .percent
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Double Extensions for Currency and Number Formatting
|
||||
|
||||
extension Double {
|
||||
/// Formats as currency (e.g., "$1,234.56")
|
||||
func toCurrency() -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
let formatter = CachedFormatters.currency
|
||||
formatter.currencyCode = "USD"
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
|
||||
}
|
||||
|
||||
/// Formats as currency with currency symbol (e.g., "$1,234.56")
|
||||
func toCurrencyString(currencyCode: String = "USD") -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
let formatter = CachedFormatters.currency
|
||||
formatter.currencyCode = currencyCode
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
|
||||
}
|
||||
|
||||
/// Formats with comma separators (e.g., "1,234.56")
|
||||
func toDecimalString(fractionDigits: Int = 2) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
let formatter = CachedFormatters.decimal
|
||||
formatter.minimumFractionDigits = fractionDigits
|
||||
formatter.maximumFractionDigits = fractionDigits
|
||||
return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self)
|
||||
@@ -45,8 +68,7 @@ extension Double {
|
||||
|
||||
/// Formats as percentage (e.g., "45.5%")
|
||||
func toPercentage(fractionDigits: Int = 1) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .percent
|
||||
let formatter = CachedFormatters.percent
|
||||
formatter.minimumFractionDigits = fractionDigits
|
||||
formatter.maximumFractionDigits = fractionDigits
|
||||
return formatter.string(from: NSNumber(value: self / 100)) ?? "\(self)%"
|
||||
@@ -78,8 +100,9 @@ extension Double {
|
||||
extension Int {
|
||||
/// Formats with comma separators (e.g., "1,234")
|
||||
func toFormattedString() -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
let formatter = CachedFormatters.decimal
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 0
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "\(self)"
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ extension String {
|
||||
|
||||
/// Validates phone number (basic check)
|
||||
var isValidPhone: Bool {
|
||||
let phoneRegex = "^[0-9+\\-\\(\\)\\s]{10,}$"
|
||||
let phoneRegex = "^(?=.*[0-9])[0-9+\\-\\(\\)\\s]{10,}$"
|
||||
let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex)
|
||||
return phonePredicate.evaluate(with: self)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,17 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Button Style
|
||||
|
||||
struct OrganicCardButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Pill Styles
|
||||
|
||||
struct MetadataPillStyle: ViewModifier {
|
||||
|
||||
@@ -6,11 +6,8 @@ struct FeatureComparisonView: View {
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@StateObject private var storeKit = StoreKitManager.shared
|
||||
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
|
||||
@State private var showUpgradePrompt = false
|
||||
@State private var selectedProduct: Product?
|
||||
@State private var isProcessing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showSuccessAlert = false
|
||||
|
||||
/// Whether the user is already subscribed from a non-iOS platform
|
||||
private var isSubscribedOnOtherPlatform: Bool {
|
||||
@@ -124,11 +121,10 @@ struct FeatureComparisonView: View {
|
||||
ForEach(storeKit.products, id: \.id) { product in
|
||||
SubscriptionButton(
|
||||
product: product,
|
||||
isSelected: selectedProduct?.id == product.id,
|
||||
isProcessing: isProcessing,
|
||||
isSelected: purchaseHelper.selectedProduct?.id == product.id,
|
||||
isProcessing: purchaseHelper.isProcessing,
|
||||
onSelect: {
|
||||
selectedProduct = product
|
||||
handlePurchase(product)
|
||||
purchaseHelper.handlePurchase(product)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -151,7 +147,7 @@ struct FeatureComparisonView: View {
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
if let error = purchaseHelper.errorMessage {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
@@ -168,7 +164,7 @@ struct FeatureComparisonView: View {
|
||||
// Restore Purchases
|
||||
if !isSubscribedOnOtherPlatform {
|
||||
Button(action: {
|
||||
handleRestore()
|
||||
purchaseHelper.handleRestore()
|
||||
}) {
|
||||
Text("Restore Purchases")
|
||||
.font(.caption)
|
||||
@@ -187,7 +183,7 @@ struct FeatureComparisonView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Subscription Active", isPresented: $showSuccessAlert) {
|
||||
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
}
|
||||
@@ -200,50 +196,6 @@ struct FeatureComparisonView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase Handling
|
||||
|
||||
private func handlePurchase(_ product: Product) {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let transaction = try await storeKit.purchase(product)
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if transaction != nil {
|
||||
showSuccessAlert = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
errorMessage = "Purchase failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRestore() {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
await storeKit.restorePurchases()
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if !storeKit.purchasedProductIDs.isEmpty {
|
||||
showSuccessAlert = true
|
||||
} else {
|
||||
errorMessage = "No purchases found to restore"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Button
|
||||
|
||||
@@ -94,6 +94,7 @@ class StoreKitManager: ObservableObject {
|
||||
print("✅ StoreKit: Purchase successful for \(product.id)")
|
||||
} catch {
|
||||
print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)")
|
||||
self.purchaseError = "Purchase successful but verification is pending. It will complete automatically."
|
||||
}
|
||||
|
||||
return transaction
|
||||
|
||||
@@ -32,7 +32,18 @@ class SubscriptionCacheWrapper: ObservableObject {
|
||||
if let subscription = currentSubscription,
|
||||
let expiresAt = subscription.expiresAt,
|
||||
!expiresAt.isEmpty {
|
||||
return "pro"
|
||||
// Parse the date and check if subscription is still active
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let expiryDate = formatter.date(from: expiresAt) ?? ISO8601DateFormatter().date(from: expiresAt) {
|
||||
if expiryDate > Date() {
|
||||
return "pro"
|
||||
}
|
||||
// Expired — fall through to StoreKit check
|
||||
} else {
|
||||
// Can't parse date but backend says there's a subscription
|
||||
return "pro"
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to local StoreKit entitlements.
|
||||
|
||||
63
iosApp/iosApp/Subscription/SubscriptionPurchaseHelper.swift
Normal file
63
iosApp/iosApp/Subscription/SubscriptionPurchaseHelper.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,10 @@ struct UpgradeFeatureView: View {
|
||||
let icon: String
|
||||
|
||||
@State private var showFeatureComparison = false
|
||||
@State private var isProcessing = false
|
||||
@State private var selectedProduct: Product?
|
||||
@State private var errorMessage: String?
|
||||
@State private var showSuccessAlert = false
|
||||
@State private var isAnimating = false
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@StateObject private var storeKit = StoreKitManager.shared
|
||||
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
|
||||
|
||||
private var triggerData: UpgradeTriggerData? {
|
||||
subscriptionCache.upgradeTriggers[triggerKey]
|
||||
@@ -155,11 +152,10 @@ struct UpgradeFeatureView: View {
|
||||
ForEach(storeKit.products, id: \.id) { product in
|
||||
SubscriptionProductButton(
|
||||
product: product,
|
||||
isSelected: selectedProduct?.id == product.id,
|
||||
isProcessing: isProcessing,
|
||||
isSelected: purchaseHelper.selectedProduct?.id == product.id,
|
||||
isProcessing: purchaseHelper.isProcessing,
|
||||
onSelect: {
|
||||
selectedProduct = product
|
||||
handlePurchase(product)
|
||||
purchaseHelper.handlePurchase(product)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -184,7 +180,7 @@ struct UpgradeFeatureView: View {
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
if let error = purchaseHelper.errorMessage {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
@@ -211,7 +207,7 @@ struct UpgradeFeatureView: View {
|
||||
|
||||
if !isSubscribedOnOtherPlatform {
|
||||
Button(action: {
|
||||
handleRestore()
|
||||
purchaseHelper.handleRestore()
|
||||
}) {
|
||||
Text("Restore Purchases")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
@@ -227,7 +223,7 @@ struct UpgradeFeatureView: View {
|
||||
.sheet(isPresented: $showFeatureComparison) {
|
||||
FeatureComparisonView(isPresented: $showFeatureComparison)
|
||||
}
|
||||
.alert("Subscription Active", isPresented: $showSuccessAlert) {
|
||||
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
|
||||
Button("Done") { }
|
||||
} message: {
|
||||
Text("You now have full access to all Pro features!")
|
||||
@@ -241,48 +237,6 @@ struct UpgradeFeatureView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePurchase(_ product: Product) {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let transaction = try await storeKit.purchase(product)
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if transaction != nil {
|
||||
showSuccessAlert = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
errorMessage = "Purchase failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRestore() {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
await storeKit.restorePurchases()
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if !storeKit.purchasedProductIDs.isEmpty {
|
||||
showSuccessAlert = true
|
||||
} else {
|
||||
errorMessage = "No purchases found to restore"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Feature Row
|
||||
|
||||
@@ -124,11 +124,8 @@ struct UpgradePromptView: View {
|
||||
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@StateObject private var storeKit = StoreKitManager.shared
|
||||
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
|
||||
@State private var showFeatureComparison = false
|
||||
@State private var isProcessing = false
|
||||
@State private var selectedProduct: Product?
|
||||
@State private var errorMessage: String?
|
||||
@State private var showSuccessAlert = false
|
||||
@State private var isAnimating = false
|
||||
|
||||
var triggerData: UpgradeTriggerData? {
|
||||
@@ -263,11 +260,10 @@ struct UpgradePromptView: View {
|
||||
ForEach(storeKit.products, id: \.id) { product in
|
||||
OrganicSubscriptionButton(
|
||||
product: product,
|
||||
isSelected: selectedProduct?.id == product.id,
|
||||
isProcessing: isProcessing,
|
||||
isSelected: purchaseHelper.selectedProduct?.id == product.id,
|
||||
isProcessing: purchaseHelper.isProcessing,
|
||||
onSelect: {
|
||||
selectedProduct = product
|
||||
handlePurchase(product)
|
||||
purchaseHelper.handlePurchase(product)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -292,7 +288,7 @@ struct UpgradePromptView: View {
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
if let error = purchaseHelper.errorMessage {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
@@ -319,7 +315,7 @@ struct UpgradePromptView: View {
|
||||
|
||||
if !isSubscribedOnOtherPlatform {
|
||||
Button(action: {
|
||||
handleRestore()
|
||||
purchaseHelper.handleRestore()
|
||||
}) {
|
||||
Text("Restore Purchases")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
@@ -347,7 +343,7 @@ struct UpgradePromptView: View {
|
||||
.sheet(isPresented: $showFeatureComparison) {
|
||||
FeatureComparisonView(isPresented: $showFeatureComparison)
|
||||
}
|
||||
.alert("Subscription Active", isPresented: $showSuccessAlert) {
|
||||
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
}
|
||||
@@ -364,48 +360,6 @@ struct UpgradePromptView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePurchase(_ product: Product) {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let transaction = try await storeKit.purchase(product)
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if transaction != nil {
|
||||
showSuccessAlert = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
errorMessage = "Purchase failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRestore() {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
await storeKit.restorePurchases()
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if !storeKit.purchasedProductIDs.isEmpty {
|
||||
showSuccessAlert = true
|
||||
} else {
|
||||
errorMessage = "No purchases found to restore"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Feature Row
|
||||
|
||||
@@ -23,7 +23,6 @@ struct HomeNavigationCard: View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(subtitle)
|
||||
|
||||
@@ -181,8 +181,8 @@ struct MyCribIconView: View {
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 1.0, green: 0.64, blue: 0.28), // #FFA347
|
||||
Color(red: 0.96, green: 0.51, blue: 0.20) // #F58233
|
||||
backgroundColor.opacity(1.0),
|
||||
backgroundColor.opacity(0.85)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatView: View {
|
||||
let icon: String
|
||||
enum IconType {
|
||||
case system(String)
|
||||
case asset(String)
|
||||
}
|
||||
|
||||
let icon: IconType
|
||||
let value: String
|
||||
let label: String
|
||||
var color: Color = Color.appPrimary
|
||||
|
||||
/// Convenience initializer that accepts a plain string for backward compatibility.
|
||||
/// Asset names are detected automatically; everything else is treated as an SF Symbol.
|
||||
init(icon: String, value: String, label: String, color: Color = Color.appPrimary) {
|
||||
if icon == "house_outline" {
|
||||
self.icon = .asset(icon)
|
||||
} else {
|
||||
self.icon = .system(icon)
|
||||
}
|
||||
self.value = value
|
||||
self.label = label
|
||||
self.color = color
|
||||
}
|
||||
|
||||
init(icon: IconType, value: String, label: String, color: Color = Color.appPrimary) {
|
||||
self.icon = icon
|
||||
self.value = value
|
||||
self.label = label
|
||||
self.color = color
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: OrganicSpacing.compact) {
|
||||
ZStack {
|
||||
@@ -13,15 +38,16 @@ struct StatView: View {
|
||||
.fill(color.opacity(0.1))
|
||||
.frame(width: 52, height: 52)
|
||||
|
||||
if icon == "house_outline" {
|
||||
Image("house_outline")
|
||||
switch icon {
|
||||
case .asset(let name):
|
||||
Image(name)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(color)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
case .system(let name):
|
||||
Image(systemName: name)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(color)
|
||||
}
|
||||
|
||||
@@ -183,38 +183,6 @@ private struct PropertyIconView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pulse Ring Animation
|
||||
|
||||
private struct PulseRing: View {
|
||||
@State private var isAnimating = false
|
||||
@State private var isPulsing = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.stroke(Color.appError.opacity(0.6), lineWidth: 2)
|
||||
.frame(width: 60, height: 60)
|
||||
.scaleEffect(isPulsing ? 1.15 : 1.0)
|
||||
.opacity(isPulsing ? 0 : 1)
|
||||
.animation(
|
||||
reduceMotion
|
||||
? .easeOut(duration: 1.5)
|
||||
: isAnimating
|
||||
? Animation.easeOut(duration: 1.5).repeatForever(autoreverses: false)
|
||||
: .default,
|
||||
value: isPulsing
|
||||
)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
isPulsing = true
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
isPulsing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Primary Badge
|
||||
|
||||
private struct PrimaryBadgeView: View {
|
||||
|
||||
@@ -4,7 +4,6 @@ import ComposeApp
|
||||
// MARK: - Share Code Card
|
||||
struct ShareCodeCard: View {
|
||||
let shareCode: ShareCodeResponse?
|
||||
let residenceName: String
|
||||
let isGeneratingCode: Bool
|
||||
let isGeneratingPackage: Bool
|
||||
let onGenerateCode: () -> Void
|
||||
@@ -131,7 +130,6 @@ struct ShareCodeCard: View {
|
||||
#Preview {
|
||||
ShareCodeCard(
|
||||
shareCode: nil,
|
||||
residenceName: "My Home",
|
||||
isGeneratingCode: false,
|
||||
isGeneratingPackage: false,
|
||||
onGenerateCode: {},
|
||||
|
||||
@@ -182,49 +182,63 @@ struct DynamicTaskCard: View {
|
||||
switch buttonType {
|
||||
case "mark_in_progress":
|
||||
Button {
|
||||
print("🔵 Mark In Progress tapped for task: \(task.id)")
|
||||
#if DEBUG
|
||||
print("Mark In Progress tapped for task: \(task.id)")
|
||||
#endif
|
||||
onMarkInProgress()
|
||||
} label: {
|
||||
Label("Mark Task In Progress", systemImage: "play.circle")
|
||||
}
|
||||
case "complete":
|
||||
Button {
|
||||
print("✅ Complete tapped for task: \(task.id)")
|
||||
#if DEBUG
|
||||
print("Complete tapped for task: \(task.id)")
|
||||
#endif
|
||||
onComplete()
|
||||
} label: {
|
||||
Label("Complete Task", systemImage: "checkmark.circle")
|
||||
}
|
||||
case "edit":
|
||||
Button {
|
||||
print("✏️ Edit tapped for task: \(task.id)")
|
||||
#if DEBUG
|
||||
print("Edit tapped for task: \(task.id)")
|
||||
#endif
|
||||
onEdit()
|
||||
} label: {
|
||||
Label("Edit Task", systemImage: "pencil")
|
||||
}
|
||||
case "cancel":
|
||||
Button(role: .destructive) {
|
||||
print("❌ Cancel tapped for task: \(task.id)")
|
||||
#if DEBUG
|
||||
print("Cancel tapped for task: \(task.id)")
|
||||
#endif
|
||||
onCancel()
|
||||
} label: {
|
||||
Label("Cancel Task", systemImage: "xmark.circle")
|
||||
}
|
||||
case "uncancel":
|
||||
Button {
|
||||
print("🔄 Restore tapped for task: \(task.id)")
|
||||
#if DEBUG
|
||||
print("Restore tapped for task: \(task.id)")
|
||||
#endif
|
||||
onUncancel()
|
||||
} label: {
|
||||
Label("Restore Task", systemImage: "arrow.uturn.backward.circle")
|
||||
}
|
||||
case "archive":
|
||||
Button {
|
||||
print("📦 Archive tapped for task: \(task.id)")
|
||||
#if DEBUG
|
||||
print("Archive tapped for task: \(task.id)")
|
||||
#endif
|
||||
onArchive()
|
||||
} label: {
|
||||
Label("Archive Task", systemImage: "archivebox")
|
||||
}
|
||||
case "unarchive":
|
||||
Button {
|
||||
print("📤 Unarchive tapped for task: \(task.id)")
|
||||
#if DEBUG
|
||||
print("Unarchive tapped for task: \(task.id)")
|
||||
#endif
|
||||
onUnarchive()
|
||||
} label: {
|
||||
Label("Unarchive Task", systemImage: "arrow.up.bin")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
// TODO: (P5) Each action button that performs an API call creates its own @StateObject TaskViewModel instance.
|
||||
// This is potentially wasteful — consider accepting a shared TaskViewModel from the parent view instead.
|
||||
// Action buttons accept a shared TaskViewModel from the parent view to avoid redundant instances.
|
||||
|
||||
// MARK: - Edit Task Button
|
||||
struct EditTaskButton: View {
|
||||
@@ -29,7 +28,7 @@ struct CancelTaskButton: View {
|
||||
let onCompletion: () -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
let viewModel: TaskViewModel
|
||||
@State private var showConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
@@ -54,7 +53,7 @@ struct CancelTaskButton: View {
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to cancel this task? This action cannot be undone.")
|
||||
Text("Are you sure you want to cancel this task? You can undo this later.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +64,7 @@ struct UncancelTaskButton: View {
|
||||
let onCompletion: () -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
let viewModel: TaskViewModel
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
@@ -92,7 +91,7 @@ struct MarkInProgressButton: View {
|
||||
let onCompletion: () -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
let viewModel: TaskViewModel
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
@@ -148,7 +147,7 @@ struct ArchiveTaskButton: View {
|
||||
let onCompletion: () -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
let viewModel: TaskViewModel
|
||||
@State private var showConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
@@ -184,7 +183,7 @@ struct UnarchiveTaskButton: View {
|
||||
let onCompletion: () -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
let viewModel: TaskViewModel
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
|
||||
@@ -82,7 +82,7 @@ struct TasksSection: View {
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
}
|
||||
.frame(height: 500)
|
||||
.frame(height: min(500, UIScreen.main.bounds.height * 0.55))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct AllTasksView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var taskViewModel = TaskViewModel()
|
||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditTask = false
|
||||
@State private var showingUpgradePrompt = false
|
||||
@State private var selectedTaskForEdit: TaskResponse?
|
||||
@State private var selectedTaskForComplete: TaskResponse?
|
||||
@@ -50,10 +48,11 @@ struct AllTasksView: View {
|
||||
residences: residenceViewModel.myResidences?.residences ?? []
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showEditTask) {
|
||||
if let task = selectedTaskForEdit {
|
||||
EditTaskView(task: task, isPresented: $showEditTask)
|
||||
}
|
||||
.sheet(item: $selectedTaskForEdit) { task in
|
||||
EditTaskView(task: task, isPresented: Binding(
|
||||
get: { selectedTaskForEdit != nil },
|
||||
set: { if !$0 { selectedTaskForEdit = nil } }
|
||||
))
|
||||
}
|
||||
.sheet(item: $selectedTaskForComplete, onDismiss: {
|
||||
if let task = pendingCompletedTask {
|
||||
@@ -138,19 +137,15 @@ struct AllTasksView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: tasksResponse) { response in
|
||||
.onChange(of: tasksResponse) { _, response in
|
||||
if let taskId = pendingTaskId, let response = response {
|
||||
navigateToTaskInKanban(taskId: taskId, response: response)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
if newPhase == .active {
|
||||
if WidgetDataManager.shared.areTasksDirty() {
|
||||
WidgetDataManager.shared.clearDirtyFlag()
|
||||
loadAllTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
// P-5: Removed redundant .onChange(of: scenePhase) handler.
|
||||
// iOSApp.swift already handles foreground refresh and widget dirty-flag
|
||||
// processing globally, so per-view scenePhase handlers fire duplicate
|
||||
// network requests.
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -185,7 +180,6 @@ struct AllTasksView: View {
|
||||
column: column,
|
||||
onEditTask: { task in
|
||||
selectedTaskForEdit = task
|
||||
showEditTask = true
|
||||
},
|
||||
onCancelTask: { task in
|
||||
selectedTaskForCancel = task
|
||||
@@ -240,7 +234,7 @@ struct AllTasksView: View {
|
||||
.padding(16)
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.onChange(of: scrollToColumnIndex) { columnIndex in
|
||||
.onChange(of: scrollToColumnIndex) { _, columnIndex in
|
||||
if let columnIndex = columnIndex {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
proxy.scrollTo(columnIndex, anchor: .leading)
|
||||
@@ -328,12 +322,16 @@ struct AllTasksView: View {
|
||||
|
||||
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
|
||||
for (index, column) in response.columns.enumerated() {
|
||||
if column.tasks.contains(where: { $0.id == taskId }) {
|
||||
if let task = column.tasks.first(where: { $0.id == taskId }) {
|
||||
pendingTaskId = nil
|
||||
PushNotificationManager.shared.clearPendingNavigation()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
self.scrollToColumnIndex = index
|
||||
}
|
||||
// Open the edit sheet for this task so the user sees its details
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
self.selectedTaskForEdit = task
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import SwiftUI
|
||||
import PhotosUI
|
||||
import ComposeApp
|
||||
|
||||
/// Wrapper to retain the Kotlin ViewModel via @StateObject
|
||||
private class CompletionViewModelHolder: ObservableObject {
|
||||
let vm = ComposeApp.TaskCompletionViewModel()
|
||||
}
|
||||
|
||||
struct CompleteTaskView: View {
|
||||
let task: TaskResponse
|
||||
let onComplete: (TaskResponse?) -> Void // Pass back updated task
|
||||
@@ -9,7 +14,8 @@ struct CompleteTaskView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var taskViewModel = TaskViewModel()
|
||||
@StateObject private var contractorViewModel = ContractorViewModel()
|
||||
private let completionViewModel = ComposeApp.TaskCompletionViewModel()
|
||||
@StateObject private var completionHolder = CompletionViewModelHolder()
|
||||
private var completionViewModel: ComposeApp.TaskCompletionViewModel { completionHolder.vm }
|
||||
@State private var completedByName: String = ""
|
||||
@State private var actualCost: String = ""
|
||||
@State private var notes: String = ""
|
||||
@@ -200,7 +206,7 @@ struct CompleteTaskView: View {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.onChange(of: selectedItems) { newItems in
|
||||
.onChange(of: selectedItems) { _, newItems in
|
||||
Task {
|
||||
selectedImages = []
|
||||
for item in newItems {
|
||||
|
||||
@@ -262,7 +262,7 @@ struct CompletionHistoryCard: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text("$\(cost)")
|
||||
Text(cost.toCurrency())
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ struct TaskFormView: View {
|
||||
if needsResidenceSelection, let residences = residences {
|
||||
Section {
|
||||
Picker(L10n.Tasks.property, selection: $selectedResidence) {
|
||||
Text(L10n.Tasks.selectProperty).tag(nil as ResidenceResponse?)
|
||||
ForEach(residences, id: \.id) { residence in
|
||||
Text(residence.name).tag(residence as ResidenceResponse?)
|
||||
}
|
||||
@@ -111,10 +110,6 @@ struct TaskFormView: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Tasks.property)
|
||||
} footer: {
|
||||
Text(L10n.Tasks.required)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.sectionBackground()
|
||||
}
|
||||
@@ -168,7 +163,7 @@ struct TaskFormView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField(L10n.Tasks.titleLabel, text: $title)
|
||||
.focused($focusedField, equals: .title)
|
||||
.onChange(of: title) { newValue in
|
||||
.onChange(of: title) { _, newValue in
|
||||
updateSuggestions(query: newValue)
|
||||
}
|
||||
|
||||
@@ -190,7 +185,6 @@ struct TaskFormView: View {
|
||||
TextField(L10n.Tasks.descriptionOptional, text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
.focused($focusedField, equals: .description)
|
||||
.keyboardDismissToolbar()
|
||||
} header: {
|
||||
Text(L10n.Tasks.taskDetails)
|
||||
} footer: {
|
||||
@@ -219,7 +213,7 @@ struct TaskFormView: View {
|
||||
Text(frequency.displayName).tag(frequency as TaskFrequency?)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedFrequency) { newFrequency in
|
||||
.onChange(of: selectedFrequency) { _, newFrequency in
|
||||
// Clear interval days if not Custom frequency
|
||||
if newFrequency?.name.lowercased() != "custom" {
|
||||
intervalDays = ""
|
||||
@@ -231,7 +225,6 @@ struct TaskFormView: View {
|
||||
TextField(L10n.Tasks.customInterval, text: $intervalDays)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .intervalDays)
|
||||
.keyboardDismissToolbar()
|
||||
}
|
||||
|
||||
DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date)
|
||||
@@ -266,7 +259,6 @@ struct TaskFormView: View {
|
||||
.focused($focusedField, equals: .estimatedCost)
|
||||
}
|
||||
.sectionBackground()
|
||||
.keyboardDismissToolbar()
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
@@ -290,11 +282,20 @@ struct TaskFormView: View {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(L10n.Common.save) {
|
||||
Button(isEditMode ? L10n.Common.save : L10n.Common.add) {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
focusedField = nil
|
||||
}
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Track screen view for new tasks
|
||||
@@ -306,17 +307,17 @@ struct TaskFormView: View {
|
||||
setDefaults()
|
||||
}
|
||||
}
|
||||
.onChange(of: dataManager.lookupsInitialized) { initialized in
|
||||
.onChange(of: dataManager.lookupsInitialized) { _, initialized in
|
||||
if initialized {
|
||||
setDefaults()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.taskCreated) { created in
|
||||
.onChange(of: viewModel.taskCreated) { _, created in
|
||||
if created {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { errorMessage in
|
||||
.onChange(of: viewModel.errorMessage) { _, errorMessage in
|
||||
if let errorMessage = errorMessage, !errorMessage.isEmpty {
|
||||
errorAlert = ErrorAlertInfo(message: errorMessage)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ struct TaskSuggestionsView: View {
|
||||
// Category-colored icon
|
||||
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(categoryColor(for: template.categoryName))
|
||||
.foregroundColor(Color.taskCategoryColor(for: template.categoryName))
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
// Task info
|
||||
@@ -78,18 +78,6 @@ struct TaskSuggestionsView: View {
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
|
||||
private func categoryColor(for categoryName: String) -> Color {
|
||||
switch categoryName.lowercased() {
|
||||
case "plumbing": return Color.appSecondary
|
||||
case "safety", "electrical": return Color.appError
|
||||
case "hvac": return Color.appPrimary
|
||||
case "appliances": return Color.appAccent
|
||||
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
|
||||
case "interior": return Color(hex: "#AF52DE") ?? .purple
|
||||
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
|
||||
default: return Color.appPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -106,7 +106,7 @@ struct TaskTemplatesBrowserView: View {
|
||||
// Category icon
|
||||
Image(systemName: categoryIcon(for: categoryGroup.categoryName))
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(categoryColor(for: categoryGroup.categoryName))
|
||||
.foregroundColor(Color.taskCategoryColor(for: categoryGroup.categoryName))
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
// Category name
|
||||
@@ -180,7 +180,7 @@ struct TaskTemplatesBrowserView: View {
|
||||
// Icon
|
||||
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(categoryColor(for: template.categoryName))
|
||||
.foregroundColor(Color.taskCategoryColor(for: template.categoryName))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
// Task info
|
||||
@@ -225,18 +225,6 @@ struct TaskTemplatesBrowserView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func categoryColor(for categoryName: String) -> Color {
|
||||
switch categoryName.lowercased() {
|
||||
case "plumbing": return Color.appSecondary
|
||||
case "safety", "electrical": return Color.appError
|
||||
case "hvac": return Color.appPrimary
|
||||
case "appliances": return Color.appAccent
|
||||
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
|
||||
case "interior": return Color(hex: "#AF52DE") ?? .purple
|
||||
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
|
||||
default: return Color.appPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -10,6 +10,8 @@ struct iOSApp: App {
|
||||
@StateObject private var residenceSharingManager = ResidenceSharingManager.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var deepLinkResetToken: String?
|
||||
/// Tracks foreground refresh tasks so they can be cancelled on subsequent transitions
|
||||
@State private var foregroundTask: Task<Void, Never>?
|
||||
@State private var pendingImportURL: URL?
|
||||
@State private var pendingImportType: CaseraPackageType = .contractor
|
||||
@State private var showImportConfirmation: Bool = false
|
||||
@@ -59,7 +61,7 @@ struct iOSApp: App {
|
||||
.onOpenURL { url in
|
||||
handleIncomingURL(url: url)
|
||||
}
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
guard !UITestRuntime.isEnabled else { return }
|
||||
|
||||
if newPhase == .active {
|
||||
@@ -73,27 +75,22 @@ struct iOSApp: App {
|
||||
// Check and register device token when app becomes active
|
||||
PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded()
|
||||
|
||||
// Refresh lookups/static data when app becomes active
|
||||
Task {
|
||||
// Cancel any previous foreground refresh task before starting a new one
|
||||
foregroundTask?.cancel()
|
||||
foregroundTask = Task { @MainActor in
|
||||
// Refresh lookups/static data
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
}
|
||||
|
||||
// Process any pending widget actions (task completions, mark in-progress)
|
||||
Task { @MainActor in
|
||||
// Process any pending widget actions (task completions, mark in-progress)
|
||||
WidgetActionProcessor.shared.processPendingActions()
|
||||
}
|
||||
|
||||
// Check if widget completed a task - refresh data globally
|
||||
if WidgetDataManager.shared.areTasksDirty() {
|
||||
WidgetDataManager.shared.clearDirtyFlag()
|
||||
Task {
|
||||
// Refresh tasks - summary is calculated client-side from kanban data
|
||||
// Check if widget completed a task - refresh data globally
|
||||
if WidgetDataManager.shared.areTasksDirty() {
|
||||
WidgetDataManager.shared.clearDirtyFlag()
|
||||
let result = try? await APILayer.shared.getTasks(forceRefresh: true)
|
||||
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
|
||||
let data = success.data {
|
||||
// Update widget cache
|
||||
WidgetDataManager.shared.saveTasks(from: data)
|
||||
// Summary is calculated by DataManager.setAllTasks() -> refreshSummaryFromKanban()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,11 +234,21 @@ struct iOSApp: App {
|
||||
.appendingPathExtension("casera")
|
||||
try data.write(to: tempURL)
|
||||
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let typeString = json["type"] as? String {
|
||||
pendingImportType = typeString == "residence" ? .residence : .contractor
|
||||
} else {
|
||||
pendingImportType = .contractor
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let typeString = json["type"] as? String {
|
||||
pendingImportType = typeString == "residence" ? .residence : .contractor
|
||||
} else {
|
||||
print("iOSApp: Casera file is valid JSON but missing 'type' field, defaulting to contractor")
|
||||
pendingImportType = .contractor
|
||||
}
|
||||
} catch {
|
||||
print("iOSApp: Failed to parse casera file JSON: \(error)")
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
contractorSharingManager.importError = "The file appears to be corrupted and could not be read."
|
||||
return
|
||||
}
|
||||
|
||||
pendingImportURL = tempURL
|
||||
@@ -262,25 +269,35 @@ struct iOSApp: App {
|
||||
|
||||
/// Handles casera:// deep links
|
||||
private func handleDeepLink(url: URL) {
|
||||
// Handle casera://reset-password?token=xxx
|
||||
guard url.host == "reset-password" else {
|
||||
#if DEBUG
|
||||
print("Unrecognized deep link host: \(url.host ?? "nil")")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
|
||||
let host = url.host
|
||||
|
||||
// Parse token from query parameters
|
||||
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let queryItems = components.queryItems,
|
||||
let token = queryItems.first(where: { $0.name == "token" })?.value {
|
||||
switch host {
|
||||
case "reset-password":
|
||||
if let token = components.queryItems?.first(where: { $0.name == "token" })?.value {
|
||||
#if DEBUG
|
||||
print("Reset token extracted: \(token)")
|
||||
#endif
|
||||
deepLinkResetToken = token
|
||||
}
|
||||
case "task":
|
||||
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
|
||||
let id = Int(idString) {
|
||||
NotificationCenter.default.post(name: .navigateToTask, object: nil, userInfo: ["taskId": id])
|
||||
}
|
||||
case "residence":
|
||||
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
|
||||
let id = Int(idString) {
|
||||
NotificationCenter.default.post(name: .navigateToResidence, object: nil, userInfo: ["residenceId": id])
|
||||
}
|
||||
case "document":
|
||||
if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
|
||||
let id = Int(idString) {
|
||||
NotificationCenter.default.post(name: .navigateToDocument, object: nil, userInfo: ["documentId": id])
|
||||
}
|
||||
default:
|
||||
#if DEBUG
|
||||
print("Reset token extracted: \(token)")
|
||||
#endif
|
||||
deepLinkResetToken = token
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("No token found in deep link")
|
||||
print("Unrecognized deep link host: \(host ?? "nil")")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user