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