Add task completion animations, subscription trials, and quiet debug console

- Completion animations: play user-selected animation on task card after completing,
  with DataManager guard to prevent race condition during animation playback.
  Works in both AllTasksView and ResidenceDetailView. Animation preference
  persisted via @AppStorage and configurable from Settings.
- Subscription: add trial fields (trialStart, trialEnd, trialActive) and
  subscriptionSource to model, cross-platform purchase guard, trial banner
  in upgrade prompt, and platform-aware subscription management in profile.
- Analytics: disable PostHog SDK debug logging and remove console print
  statements to reduce debug console noise.
- Documents: remove redundant nested do-catch blocks in ViewModel wrapper.
- Widgets: add debounced timeline reloads and thread-safe file I/O queue.
- Onboarding: fix animation leak on disappear, remove unused state vars.
- Remove unused files (ContentView, StateFlowExtensions, CustomView).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-05 11:35:08 -06:00
parent c5f2bee83f
commit 98dbacdea0
73 changed files with 1770 additions and 529 deletions

View File

@@ -45,8 +45,9 @@ private let proLimits = TierLimits(
documents: nil
)
// MARK: - Serialized Suite (SubscriptionCacheWrapper is a shared singleton)
// MARK: - Serialized Suite (SubscriptionCacheWrapper is a @MainActor shared singleton)
@MainActor
@Suite(.serialized)
struct SubscriptionGatingTests {

View File

@@ -63,7 +63,7 @@ final class AnalyticsManager {
}
#if DEBUG
config.debug = true
config.debug = false
config.flushAt = 1
#endif
@@ -78,9 +78,6 @@ final class AnalyticsManager {
func track(_ event: AnalyticsEvent) {
guard isConfigured else { return }
let (name, properties) = event.payload
#if DEBUG
print("[Analytics] \(name)", properties ?? [:])
#endif
PostHogSDK.shared.capture(name, properties: properties)
}
@@ -90,9 +87,6 @@ final class AnalyticsManager {
guard isConfigured else { return }
var props: [String: Any] = ["screen_name": screen.rawValue]
if let properties { props.merge(properties) { _, new in new } }
#if DEBUG
print("[Analytics] screen_viewed: \(screen.rawValue)")
#endif
PostHogSDK.shared.capture("screen_viewed", properties: props)
}

View File

@@ -1,3 +0,0 @@
import SwiftUI
import ComposeApp

View File

@@ -240,11 +240,11 @@ struct ContractorDetailView: View {
@ViewBuilder
private func quickActionsView(contractor: Contractor) -> some View {
let hasPhone = contractor.phone != nil && !contractor.phone!.isEmpty
let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
let hasAddress = (contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty) ||
(contractor.city != nil && !contractor.city!.isEmpty)
let hasPhone = !(contractor.phone?.isEmpty ?? true)
let hasEmail = !(contractor.email?.isEmpty ?? true)
let hasWebsite = !(contractor.website?.isEmpty ?? true)
let hasAddress = !(contractor.streetAddress?.isEmpty ?? true) ||
!(contractor.city?.isEmpty ?? true)
if hasPhone || hasEmail || hasWebsite || hasAddress {
HStack(spacing: AppSpacing.sm) {
@@ -307,8 +307,8 @@ struct ContractorDetailView: View {
@ViewBuilder
private func directionsQuickAction(contractor: Contractor) -> some View {
let hasAddress = (contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty) ||
(contractor.city != nil && !contractor.city!.isEmpty)
let hasAddress = !(contractor.streetAddress?.isEmpty ?? true) ||
!(contractor.city?.isEmpty ?? true)
if hasAddress {
QuickActionButton(
icon: "map.fill",
@@ -334,9 +334,9 @@ struct ContractorDetailView: View {
@ViewBuilder
private func contactInfoSection(contractor: Contractor) -> some View {
let hasPhone = contractor.phone != nil && !contractor.phone!.isEmpty
let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
let hasPhone = !(contractor.phone?.isEmpty ?? true)
let hasEmail = !(contractor.email?.isEmpty ?? true)
let hasWebsite = !(contractor.website?.isEmpty ?? true)
if hasPhone || hasEmail || hasWebsite {
DetailSection(title: L10n.Contractors.contactInfoSection) {
@@ -403,8 +403,8 @@ struct ContractorDetailView: View {
@ViewBuilder
private func addressSection(contractor: Contractor) -> some View {
let hasStreet = contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty
let hasCity = contractor.city != nil && !contractor.city!.isEmpty
let hasStreet = !(contractor.streetAddress?.isEmpty ?? true)
let hasCity = !(contractor.city?.isEmpty ?? true)
if hasStreet || hasCity {
let addressComponents = [

View File

@@ -224,15 +224,19 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
Group {
if let errorMessage = errorMessage, items.isEmpty {
// Wrap in ScrollView for pull-to-refresh support
ScrollView {
DefaultErrorView(message: errorMessage, onRetry: onRetry)
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
GeometryReader { geometry in
ScrollView {
DefaultErrorView(message: errorMessage, onRetry: onRetry)
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
}
}
} else if items.isEmpty && !isLoading {
// Wrap in ScrollView for pull-to-refresh support
ScrollView {
emptyContent()
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
GeometryReader { geometry in
ScrollView {
emptyContent()
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
}
}
} else {
content(items)
@@ -244,7 +248,10 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
}
}
.refreshable {
onRefresh()
await withCheckedContinuation { continuation in
onRefresh()
continuation.resume()
}
}
}
}

View File

@@ -85,21 +85,22 @@ struct DocumentFormState: FormState {
// MARK: - Date Formatting
private var dateFormatter: DateFormatter {
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}
}()
var purchaseDateString: String? {
purchaseDate.map { dateFormatter.string(from: $0) }
purchaseDate.map { Self.dateFormatter.string(from: $0) }
}
var startDateString: String? {
startDate.map { dateFormatter.string(from: $0) }
startDate.map { Self.dateFormatter.string(from: $0) }
}
var endDateString: String? {
endDate.map { dateFormatter.string(from: $0) }
endDate.map { Self.dateFormatter.string(from: $0) }
}
}

View File

@@ -82,11 +82,17 @@ struct FormField<T> {
error = nil
}
/// Check if field is valid (no error)
/// Check if field has no error. Note: returns true for fields that have
/// never been validated use `isValidated` to confirm validation has run.
var isValid: Bool {
error == nil
}
/// True only after `validate()` has been called and produced no error
var isValidated: Bool {
isDirty && error == nil
}
/// Check if field should show error (dirty and has error)
var shouldShowError: Bool {
isDirty && error != nil

View File

@@ -10,7 +10,9 @@ extension Color {
private static func themed(_ name: String) -> Color {
// Both main app and widgets use the theme from ThemeManager
// Theme is shared via App Group UserDefaults
let theme = ThemeManager.shared.currentTheme.rawValue
let theme = MainActor.assumeIsolated {
ThemeManager.shared.currentTheme.rawValue
}
return Color("\(theme)/\(name)", bundle: nil)
}

View File

@@ -25,7 +25,7 @@ struct DocumentsTabContent: View {
DocumentsListContent(documents: documents)
},
emptyContent: {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredDocuments.count, limitKey: "documents") {
EmptyStateView(
icon: "doc",
title: L10n.Documents.noDocumentsFound,

View File

@@ -7,7 +7,7 @@ struct ImageViewerSheet: View {
let onDismiss: () -> Void
var body: some View {
NavigationView {
NavigationStack {
TabView(selection: $selectedIndex) {
ForEach(Array(images.enumerated()), id: \.element.id) { index, image in
ZStack {

View File

@@ -27,7 +27,7 @@ struct WarrantiesTabContent: View {
WarrantiesListContent(warranties: warranties)
},
emptyContent: {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredWarranties.count, limitKey: "documents") {
EmptyStateView(
icon: "doc.text.viewfinder",
title: L10n.Documents.noWarrantiesFound,

View File

@@ -26,9 +26,9 @@ struct DocumentDetailView: View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundColor(.red)
.foregroundColor(Color.appError)
Text(errorState.message)
.foregroundColor(.secondary)
.foregroundColor(Color.appTextSecondary)
Button(L10n.Common.retry) {
viewModel.loadDocumentDetail(id: documentId)
}
@@ -40,19 +40,11 @@ struct DocumentDetailView: View {
}
.navigationTitle(L10n.Documents.documentDetails)
.navigationBarTitleDisplayMode(.inline)
.background(
// Hidden NavigationLink for programmatic navigation to edit
Group {
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
NavigationLink(
destination: EditDocumentView(document: successState.document),
isActive: $navigateToEdit
) {
EmptyView()
}
}
.navigationDestination(isPresented: $navigateToEdit) {
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
EditDocumentView(document: successState.document)
}
)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.documentDetailState is DocumentDetailStateSuccess {
@@ -343,7 +335,7 @@ struct DocumentDetailView: View {
detailRow(label: L10n.Documents.residence, value: "Residence #\(residenceId)")
}
if let taskId = document.taskId {
detailRow(label: L10n.Documents.contractor, value: "Task #\(taskId)")
detailRow(label: "Task", value: "Task #\(taskId)")
}
}
.padding()
@@ -499,15 +491,15 @@ struct DocumentDetailView: View {
private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color {
if !isActive {
return .gray
return Color.appTextSecondary
} else if daysUntilExpiration < 0 {
return .red
return Color.appError
} else if daysUntilExpiration < 30 {
return .orange
return Color.appAccent
} else if daysUntilExpiration < 90 {
return .yellow
return Color.appAccent.opacity(0.8)
} else {
return .green
return Color.appPrimary
}
}

View File

@@ -124,20 +124,16 @@ class DocumentViewModelWrapper: ObservableObject {
forceRefresh: false
)
do {
if let success = result as? ApiResultSuccess<NSArray> {
let documents = success.data as? [Document] ?? []
self.documentsState = DocumentStateSuccess(documents: documents)
} else if let error = ApiResultBridge.error(from: result) {
self.documentsState = DocumentStateError(message: error.message)
} else {
self.documentsState = DocumentStateError(message: "Failed to load documents")
}
if let success = result as? ApiResultSuccess<NSArray> {
let documents = success.data as? [Document] ?? []
self.documentsState = DocumentStateSuccess(documents: documents)
} else if let error = ApiResultBridge.error(from: result) {
self.documentsState = DocumentStateError(message: error.message)
} else {
self.documentsState = DocumentStateError(message: "Failed to load documents")
}
} catch {
do {
self.documentsState = DocumentStateError(message: error.localizedDescription)
}
self.documentsState = DocumentStateError(message: error.localizedDescription)
}
}
}
@@ -150,19 +146,15 @@ class DocumentViewModelWrapper: ObservableObject {
do {
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
do {
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.documentDetailState = DocumentDetailStateError(message: error.message)
} else {
self.documentDetailState = DocumentDetailStateError(message: "Failed to load document")
}
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.documentDetailState = DocumentDetailStateError(message: error.message)
} else {
self.documentDetailState = DocumentDetailStateError(message: "Failed to load document")
}
} catch {
do {
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
}
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
}
}
}
@@ -215,21 +207,17 @@ class DocumentViewModelWrapper: ObservableObject {
endDate: endDate
)
do {
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.updateState = UpdateStateSuccess(document: document)
// Also refresh the detail state
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.updateState = UpdateStateError(message: error.message)
} else {
self.updateState = UpdateStateError(message: "Failed to update document")
}
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.updateState = UpdateStateSuccess(document: document)
// Also refresh the detail state
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.updateState = UpdateStateError(message: error.message)
} else {
self.updateState = UpdateStateError(message: "Failed to update document")
}
} catch {
do {
self.updateState = UpdateStateError(message: error.localizedDescription)
}
self.updateState = UpdateStateError(message: error.localizedDescription)
}
}
}
@@ -241,19 +229,15 @@ class DocumentViewModelWrapper: ObservableObject {
do {
let result = try await APILayer.shared.deleteDocument(id: id)
do {
if result is ApiResultSuccess<KotlinUnit> {
self.deleteState = DeleteStateSuccess()
} else if let error = ApiResultBridge.error(from: result) {
self.deleteState = DeleteStateError(message: error.message)
} else {
self.deleteState = DeleteStateError(message: "Failed to delete document")
}
if result is ApiResultSuccess<KotlinUnit> {
self.deleteState = DeleteStateSuccess()
} else if let error = ApiResultBridge.error(from: result) {
self.deleteState = DeleteStateError(message: error.message)
} else {
self.deleteState = DeleteStateError(message: "Failed to delete document")
}
} catch {
do {
self.deleteState = DeleteStateError(message: error.localizedDescription)
}
self.deleteState = DeleteStateError(message: error.localizedDescription)
}
}
}
@@ -273,21 +257,17 @@ class DocumentViewModelWrapper: ObservableObject {
do {
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
do {
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.deleteImageState = DeleteImageStateSuccess()
// Refresh detail state with updated document (image removed)
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.deleteImageState = DeleteImageStateError(message: error.message)
} else {
self.deleteImageState = DeleteImageStateError(message: "Failed to delete image")
}
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.deleteImageState = DeleteImageStateSuccess()
// Refresh detail state with updated document (image removed)
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = ApiResultBridge.error(from: result) {
self.deleteImageState = DeleteImageStateError(message: error.message)
} else {
self.deleteImageState = DeleteImageStateError(message: "Failed to delete image")
}
} catch {
do {
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
}
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
}
}
}

View File

@@ -206,21 +206,11 @@ struct DocumentsWarrantiesView: View {
selectedTab = .warranties
}
}
.background(
NavigationLink(
destination: Group {
if let documentId = pushTargetDocumentId {
DocumentDetailView(documentId: documentId)
} else {
EmptyView()
}
},
isActive: $navigateToPushDocument
) {
EmptyView()
.navigationDestination(isPresented: $navigateToPushDocument) {
if let documentId = pushTargetDocumentId {
DocumentDetailView(documentId: documentId)
}
.hidden()
)
}
}
private func loadAllDocuments(forceRefresh: Bool = false) {

View File

@@ -1,7 +1,7 @@
import Foundation
struct DocumentTypeHelper {
static let allTypes = ["warranty", "manual", "receipt", "inspection", "insurance", "other"]
static let allTypes = ["warranty", "manual", "receipt", "inspection", "permit", "deed", "insurance", "contract", "photo", "other"]
static func displayName(for value: String) -> String {
switch value {
@@ -20,7 +20,7 @@ struct DocumentTypeHelper {
}
struct DocumentCategoryHelper {
static let allCategories = ["appliance", "hvac", "plumbing", "electrical", "roofing", "flooring", "other"]
static let allCategories = ["appliance", "hvac", "plumbing", "electrical", "roofing", "structural", "landscaping", "general", "other"]
static func displayName(for value: String) -> String {
switch value {

View File

@@ -7,6 +7,7 @@ enum DateUtils {
private static let isoDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()

View File

@@ -39,7 +39,7 @@ enum UITestRuntime {
UserDefaults.standard.set(true, forKey: "ui_testing_mode")
}
static func resetStateIfRequested() {
@MainActor static func resetStateIfRequested() {
guard shouldResetState else { return }
DataManager.shared.clear()

View File

@@ -23,16 +23,16 @@ final class WidgetActionProcessor {
return
}
let actions = WidgetDataManager.shared.loadPendingActions()
guard !actions.isEmpty else {
print("WidgetActionProcessor: No pending actions")
return
}
Task {
let actions = await WidgetDataManager.shared.loadPendingActions()
guard !actions.isEmpty else {
print("WidgetActionProcessor: No pending actions")
return
}
print("WidgetActionProcessor: Processing \(actions.count) pending action(s)")
print("WidgetActionProcessor: Processing \(actions.count) pending action(s)")
for action in actions {
Task {
for action in actions {
await processAction(action)
}
}

View File

@@ -7,6 +7,11 @@ import ComposeApp
final class WidgetDataManager {
static let shared = WidgetDataManager()
/// Tracks the last time `reloadAllTimelines()` was called for debouncing
private static var lastReloadTime: Date = .distantPast
/// Minimum interval between `reloadAllTimelines()` calls (seconds)
private static let reloadDebounceInterval: TimeInterval = 2.0
// MARK: - API Column Names (Single Source of Truth)
// These match the column names returned by the API's task columns endpoint
static let overdueColumn = "overdue_tasks"
@@ -25,19 +30,31 @@ final class WidgetDataManager {
private let limitationsEnabledKey = "widget_limitations_enabled"
private let isPremiumKey = "widget_is_premium"
/// Serial queue for thread-safe file I/O operations
private let fileQueue = DispatchQueue(label: "com.casera.widget.fileio")
private var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: appGroupIdentifier)
}
private init() {}
/// Reload all widget timelines, debounced to avoid excessive reloads.
/// Only triggers a reload if at least `reloadDebounceInterval` has elapsed since the last reload.
private func reloadWidgetTimelinesIfNeeded() {
let now = Date()
if now.timeIntervalSince(Self.lastReloadTime) >= Self.reloadDebounceInterval {
Self.lastReloadTime = now
WidgetCenter.shared.reloadAllTimelines()
}
}
// MARK: - Auth Token Sharing
/// Save auth token to shared App Group for widget access
/// Call this after successful login or when token is refreshed
func saveAuthToken(_ token: String) {
sharedDefaults?.set(token, forKey: tokenKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Saved auth token to shared container")
}
@@ -51,14 +68,12 @@ final class WidgetDataManager {
/// Call this on logout
func clearAuthToken() {
sharedDefaults?.removeObject(forKey: tokenKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Cleared auth token from shared container")
}
/// Save API base URL to shared container for widget
func saveAPIBaseURL(_ url: String) {
sharedDefaults?.set(url, forKey: apiBaseURLKey)
sharedDefaults?.synchronize()
}
/// Get API base URL from shared container
@@ -73,7 +88,6 @@ final class WidgetDataManager {
func saveSubscriptionStatus(limitationsEnabled: Bool, isPremium: Bool) {
sharedDefaults?.set(limitationsEnabled, forKey: limitationsEnabledKey)
sharedDefaults?.set(isPremium, forKey: isPremiumKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Saved subscription status - limitations=\(limitationsEnabled), premium=\(isPremium)")
// Reload widget to reflect new subscription status
WidgetCenter.shared.reloadAllTimelines()
@@ -104,7 +118,6 @@ final class WidgetDataManager {
/// Called by widget after completing a task
func markTasksDirty() {
sharedDefaults?.set(true, forKey: dirtyFlagKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Marked tasks as dirty")
}
@@ -116,7 +129,6 @@ final class WidgetDataManager {
/// Clear dirty flag after refreshing tasks
func clearDirtyFlag() {
sharedDefaults?.set(false, forKey: dirtyFlagKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Cleared dirty flag")
}
@@ -142,56 +154,100 @@ final class WidgetDataManager {
// MARK: - Pending Action Processing
/// Load pending actions queued by the widget
func loadPendingActions() -> [WidgetAction] {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName),
FileManager.default.fileExists(atPath: fileURL.path) else {
/// Load pending actions queued by the widget (async, non-blocking)
func loadPendingActions() async -> [WidgetAction] {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
return []
}
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([WidgetAction].self, from: data)
} catch {
print("WidgetDataManager: Error loading pending actions - \(error)")
return await withCheckedContinuation { continuation in
fileQueue.async {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
continuation.resume(returning: [])
return
}
do {
let data = try Data(contentsOf: fileURL)
let actions = try JSONDecoder().decode([WidgetAction].self, from: data)
continuation.resume(returning: actions)
} catch {
print("WidgetDataManager: Error loading pending actions - \(error)")
continuation.resume(returning: [])
}
}
}
}
/// Load pending actions synchronously (blocks calling thread).
/// Prefer the async overload from the main app. This is kept for widget extension use.
func loadPendingActionsSync() -> [WidgetAction] {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
return []
}
return fileQueue.sync {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return []
}
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([WidgetAction].self, from: data)
} catch {
print("WidgetDataManager: Error loading pending actions - \(error)")
return []
}
}
}
/// Clear all pending actions after processing
func clearPendingActions() {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared pending actions")
} catch {
// File might not exist
fileQueue.async {
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared pending actions")
} catch {
// File might not exist
}
}
}
/// Remove a specific action after processing
func removeAction(_ action: WidgetAction) {
var actions = loadPendingActions()
actions.removeAll { $0 == action }
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
if actions.isEmpty {
clearPendingActions()
} else {
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
do {
let data = try JSONEncoder().encode(actions)
try data.write(to: fileURL, options: .atomic)
} catch {
print("WidgetDataManager: Error saving actions - \(error)")
fileQueue.async {
// Load actions within the serial queue to avoid race conditions
var actions: [WidgetAction]
if FileManager.default.fileExists(atPath: fileURL.path),
let data = try? Data(contentsOf: fileURL),
let decoded = try? JSONDecoder().decode([WidgetAction].self, from: data) {
actions = decoded
} else {
actions = []
}
actions.removeAll { $0 == action }
if actions.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
} else {
do {
let data = try JSONEncoder().encode(actions)
try data.write(to: fileURL, options: .atomic)
} catch {
print("WidgetDataManager: Error saving actions - \(error)")
}
}
}
}
/// Clear pending state for a task after it's been synced
func clearPendingState(forTaskId taskId: Int) {
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName),
FileManager.default.fileExists(atPath: fileURL.path) else {
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName) else {
return
}
@@ -201,28 +257,36 @@ final class WidgetDataManager {
let timestamp: Date
}
do {
let data = try Data(contentsOf: fileURL)
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
states.removeAll { $0.taskId == taskId }
fileQueue.async {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return
}
if states.isEmpty {
try FileManager.default.removeItem(at: fileURL)
} else {
let updatedData = try JSONEncoder().encode(states)
try updatedData.write(to: fileURL, options: .atomic)
do {
let data = try Data(contentsOf: fileURL)
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
states.removeAll { $0.taskId == taskId }
if states.isEmpty {
try FileManager.default.removeItem(at: fileURL)
} else {
let updatedData = try JSONEncoder().encode(states)
try updatedData.write(to: fileURL, options: .atomic)
}
} catch {
print("WidgetDataManager: Error clearing pending state - \(error)")
}
// Reload widget to reflect the change
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
} catch {
print("WidgetDataManager: Error clearing pending state - \(error)")
DispatchQueue.main.async {
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
}
}
}
/// Check if there are any pending actions from the widget
var hasPendingActions: Bool {
!loadPendingActions().isEmpty
!loadPendingActionsSync().isEmpty
}
/// Task model for widget display - simplified version of TaskDetail
@@ -285,6 +349,7 @@ final class WidgetDataManager {
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
@@ -364,47 +429,82 @@ final class WidgetDataManager {
}
}
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(allTasks)
try data.write(to: fileURL, options: .atomic)
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
fileQueue.async {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(allTasks)
try data.write(to: fileURL, options: .atomic)
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
} catch {
print("WidgetDataManager: Error saving tasks - \(error)")
}
// Reload widget timeline
WidgetCenter.shared.reloadAllTimelines()
} catch {
print("WidgetDataManager: Error saving tasks - \(error)")
// Reload widget timeline (debounced) after file write completes
DispatchQueue.main.async {
self.reloadWidgetTimelinesIfNeeded()
}
}
}
/// Load tasks from the shared container
/// Used by the widget to read cached data
func loadTasks() -> [WidgetTask] {
/// Load tasks from the shared container (async, non-blocking)
func loadTasks() async -> [WidgetTask] {
guard let fileURL = tasksFileURL else {
print("WidgetDataManager: Unable to access shared container")
return []
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("WidgetDataManager: No cached tasks file found")
return await withCheckedContinuation { continuation in
fileQueue.async {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("WidgetDataManager: No cached tasks file found")
continuation.resume(returning: [])
return
}
do {
let data = try Data(contentsOf: fileURL)
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
continuation.resume(returning: tasks)
} catch {
print("WidgetDataManager: Error loading tasks - \(error)")
continuation.resume(returning: [])
}
}
}
}
/// Load tasks synchronously (blocks calling thread).
/// Prefer the async overload from the main app. This is kept for widget extension use.
func loadTasksSync() -> [WidgetTask] {
guard let fileURL = tasksFileURL else {
print("WidgetDataManager: Unable to access shared container")
return []
}
do {
let data = try Data(contentsOf: fileURL)
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
return tasks
} catch {
print("WidgetDataManager: Error loading tasks - \(error)")
return []
return fileQueue.sync {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("WidgetDataManager: No cached tasks file found")
return []
}
do {
let data = try Data(contentsOf: fileURL)
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
return tasks
} catch {
print("WidgetDataManager: Error loading tasks - \(error)")
return []
}
}
}
/// Get upcoming/pending tasks for widget display
/// Uses synchronous loading since this is typically called from widget timeline providers
func getUpcomingTasks() -> [WidgetTask] {
let allTasks = loadTasks()
let allTasks = loadTasksSync()
// All loaded tasks are already filtered (archived and completed columns are excluded during save)
// Sort by due date (earliest first), with overdue at top
@@ -426,12 +526,17 @@ final class WidgetDataManager {
func clearCache() {
guard let fileURL = tasksFileURL else { return }
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared widget cache")
WidgetCenter.shared.reloadAllTimelines()
} catch {
print("WidgetDataManager: Error clearing cache - \(error)")
fileQueue.async {
do {
try FileManager.default.removeItem(at: fileURL)
print("WidgetDataManager: Cleared widget cache")
} catch {
print("WidgetDataManager: Error clearing cache - \(error)")
}
DispatchQueue.main.async {
WidgetCenter.shared.reloadAllTimelines()
}
}
}

View File

@@ -6,8 +6,6 @@
<array>
<string>com.tt.casera.refresh</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CASERA_IAP_ANNUAL_PRODUCT_ID</key>
<string>com.example.casera.pro.annual</string>
<key>CASERA_IAP_MONTHLY_PRODUCT_ID</key>
@@ -61,7 +59,6 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>UTExportedTypeDeclarations</key>

View File

@@ -137,8 +137,15 @@
"4.9" : {
},
"7-day free trial, then %@" : {
"7-day free trial, then %@%@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "7-day free trial, then %1$@%2$@"
}
}
}
},
"ABC123" : {
@@ -171,10 +178,6 @@
"comment" : "A link that directs users to log in if they already have an account.",
"isCommentAutoGenerated" : true
},
"Animation Testing" : {
"comment" : "The title of a view that tests different animations.",
"isCommentAutoGenerated" : true
},
"Animation Type" : {
"comment" : "A label above the picker for selecting an animation type.",
"isCommentAutoGenerated" : true
@@ -5251,6 +5254,10 @@
"comment" : "A button label that says \"Complete Task\".",
"isCommentAutoGenerated" : true
},
"Completion Animation" : {
"comment" : "The title of the view.",
"isCommentAutoGenerated" : true
},
"Completion Photos" : {
"comment" : "The title for the view that shows a user's photo submissions.",
"isCommentAutoGenerated" : true
@@ -17334,6 +17341,9 @@
"Free" : {
"comment" : "A label indicating a free feature.",
"isCommentAutoGenerated" : true
},
"Free trial ends %@" : {
},
"Generate Code" : {
"comment" : "A button label that generates a new invitation code.",
@@ -17430,6 +17440,16 @@
},
"Logging in..." : {
},
"Manage at casera.app" : {
},
"Manage your subscription at casera.app" : {
"comment" : "A text instruction that directs them to manage their subscription on casera.app.",
"isCommentAutoGenerated" : true
},
"Manage your subscription on your Android device" : {
},
"Mark Task In Progress" : {
"comment" : "A button label that says \"Mark Task In Progress\".",
@@ -17490,6 +17510,10 @@
"comment" : "A button that dismisses the success dialog.",
"isCommentAutoGenerated" : true
},
"Open casera.app" : {
"comment" : "A button label that opens the casera.app settings page.",
"isCommentAutoGenerated" : true
},
"or" : {
},
@@ -30112,6 +30136,10 @@
},
"You're all set up!" : {
},
"You're already subscribed" : {
"comment" : "A message displayed when a user is already subscribed to the app.",
"isCommentAutoGenerated" : true
},
"Your data will be synced across devices" : {
@@ -30123,6 +30151,13 @@
"Your home maintenance companion" : {
"comment" : "The tagline for the app, describing its purpose.",
"isCommentAutoGenerated" : true
},
"Your subscription is managed on another platform." : {
"comment" : "A description of a user's subscription on an unspecified platform.",
"isCommentAutoGenerated" : true
},
"Your subscription is managed through Google Play on your Android device." : {
}
},
"version" : "1.1"

View File

@@ -1,7 +1,9 @@
import Foundation
import AuthenticationServices
import UIKit
/// Handles Sign in with Apple authentication flow
@MainActor
class AppleSignInManager: NSObject, ObservableObject {
// MARK: - Published Properties
@Published var isProcessing: Bool = false
@@ -32,95 +34,101 @@ class AppleSignInManager: NSObject, ObservableObject {
// MARK: - ASAuthorizationControllerDelegate
extension AppleSignInManager: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
isProcessing = false
nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
Task { @MainActor in
isProcessing = false
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
let error = AppleSignInError.invalidCredential
self.error = error
completionHandler?(.failure(error))
return
}
// Get the identity token as a string
guard let identityTokenData = appleIDCredential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
let error = AppleSignInError.missingIdentityToken
self.error = error
completionHandler?(.failure(error))
return
}
// Extract user info (only available on first sign in)
let email = appleIDCredential.email
let firstName = appleIDCredential.fullName?.givenName
let lastName = appleIDCredential.fullName?.familyName
let userIdentifier = appleIDCredential.user
let credential = AppleSignInCredential(
identityToken: identityToken,
userIdentifier: userIdentifier,
email: email,
firstName: firstName,
lastName: lastName
)
completionHandler?(.success(credential))
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
isProcessing = false
// Check if user cancelled
if let authError = error as? ASAuthorizationError {
switch authError.code {
case .canceled:
// User cancelled, don't treat as error
self.error = AppleSignInError.userCancelled
completionHandler?(.failure(AppleSignInError.userCancelled))
return
case .failed:
self.error = AppleSignInError.authorizationFailed
completionHandler?(.failure(AppleSignInError.authorizationFailed))
return
case .invalidResponse:
self.error = AppleSignInError.invalidResponse
completionHandler?(.failure(AppleSignInError.invalidResponse))
return
case .notHandled:
self.error = AppleSignInError.notHandled
completionHandler?(.failure(AppleSignInError.notHandled))
return
case .notInteractive:
self.error = AppleSignInError.notInteractive
completionHandler?(.failure(AppleSignInError.notInteractive))
return
default:
self.error = AppleSignInError.authorizationFailed
completionHandler?(.failure(AppleSignInError.authorizationFailed))
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
let error = AppleSignInError.invalidCredential
self.error = error
completionHandler?(.failure(error))
return
}
}
self.error = error
completionHandler?(.failure(error))
// Get the identity token as a string
guard let identityTokenData = appleIDCredential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
let error = AppleSignInError.missingIdentityToken
self.error = error
completionHandler?(.failure(error))
return
}
// Extract user info (only available on first sign in)
let email = appleIDCredential.email
let firstName = appleIDCredential.fullName?.givenName
let lastName = appleIDCredential.fullName?.familyName
let userIdentifier = appleIDCredential.user
let credential = AppleSignInCredential(
identityToken: identityToken,
userIdentifier: userIdentifier,
email: email,
firstName: firstName,
lastName: lastName
)
completionHandler?(.success(credential))
}
}
nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
Task { @MainActor in
isProcessing = false
// Check if user cancelled
if let authError = error as? ASAuthorizationError {
switch authError.code {
case .canceled:
// User cancelled, don't treat as error
self.error = AppleSignInError.userCancelled
completionHandler?(.failure(AppleSignInError.userCancelled))
return
case .failed:
self.error = AppleSignInError.authorizationFailed
completionHandler?(.failure(AppleSignInError.authorizationFailed))
return
case .invalidResponse:
self.error = AppleSignInError.invalidResponse
completionHandler?(.failure(AppleSignInError.invalidResponse))
return
case .notHandled:
self.error = AppleSignInError.notHandled
completionHandler?(.failure(AppleSignInError.notHandled))
return
case .notInteractive:
self.error = AppleSignInError.notInteractive
completionHandler?(.failure(AppleSignInError.notInteractive))
return
default:
self.error = AppleSignInError.authorizationFailed
completionHandler?(.failure(AppleSignInError.authorizationFailed))
return
}
}
self.error = error
completionHandler?(.failure(error))
}
}
}
// MARK: - ASAuthorizationControllerPresentationContextProviding
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
// Get the key window for presentation
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
// Fallback to first window
return UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first ?? ASPresentationAnchor()
nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
MainActor.assumeIsolated {
// Get the key window for presentation
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
// Fallback to first window
return UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first ?? ASPresentationAnchor()
}
return window
}
return window
}
}

View File

@@ -10,7 +10,7 @@ struct LoginView: View {
@State private var showPasswordReset = false
@State private var isPasswordVisible = false
@State private var activeResetToken: String?
@StateObject private var googleSignInManager = GoogleSignInManager.shared
@ObservedObject private var googleSignInManager = GoogleSignInManager.shared
@Binding var resetToken: String?
var onLoginSuccess: (() -> Void)?
@@ -29,7 +29,7 @@ struct LoginView: View {
}
var body: some View {
NavigationView {
NavigationStack {
ZStack {
// Warm organic background
WarmGradientBackground()

View File

@@ -3,13 +3,13 @@ import SwiftUI
struct MainTabView: View {
@EnvironmentObject private var themeManager: ThemeManager
@State private var selectedTab = 0
@StateObject private var authManager = AuthenticationManager.shared
@ObservedObject private var authManager = AuthenticationManager.shared
@ObservedObject private var pushManager = PushNotificationManager.shared
var refreshID: UUID
var body: some View {
TabView(selection: $selectedTab) {
NavigationView {
NavigationStack {
ResidencesListView()
}
.id(refreshID)
@@ -19,7 +19,7 @@ struct MainTabView: View {
.tag(0)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
NavigationView {
NavigationStack {
AllTasksView()
}
.id(refreshID)
@@ -29,7 +29,7 @@ struct MainTabView: View {
.tag(1)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
NavigationView {
NavigationStack {
ContractorsListView()
}
.id(refreshID)
@@ -39,7 +39,7 @@ struct MainTabView: View {
.tag(2)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
NavigationView {
NavigationStack {
DocumentsWarrantiesView(residenceId: nil)
}
.id(refreshID)
@@ -50,7 +50,7 @@ struct MainTabView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
}
.tint(Color.appPrimary)
.onChange(of: authManager.isAuthenticated) { _ in
.onChange(of: authManager.isAuthenticated) { _, _ in
selectedTab = 0
}
.onAppear {

View File

@@ -3,10 +3,8 @@ import ComposeApp
/// Coordinates the onboarding flow, presenting the appropriate view based on current step
struct OnboardingCoordinator: View {
@StateObject private var onboardingState = OnboardingState.shared
@ObservedObject private var onboardingState = OnboardingState.shared
@StateObject private var residenceViewModel = ResidenceViewModel()
@State private var showingRegister = false
@State private var showingLogin = false
@State private var isNavigatingBack = false
@State private var isCreatingResidence = false

View File

@@ -11,7 +11,7 @@ struct OnboardingCreateAccountContent: View {
@State private var showingLoginSheet = false
@State private var isExpanded = false
@State private var isAnimating = false
@StateObject private var googleSignInManager = GoogleSignInManager.shared
@ObservedObject private var googleSignInManager = GoogleSignInManager.shared
@FocusState private var focusedField: Field?
@Environment(\.colorScheme) var colorScheme
@@ -72,7 +72,9 @@ struct OnboardingCreateAccountContent: View {
.frame(width: 120, height: 120)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
@@ -353,6 +355,9 @@ struct OnboardingCreateAccountContent: View {
onAccountCreated(isVerified)
}
}
.onDisappear {
isAnimating = false
}
}
}

View File

@@ -11,7 +11,6 @@ struct OnboardingFirstTaskContent: View {
@ObservedObject private var onboardingState = OnboardingState.shared
@State private var selectedTasks: Set<UUID> = []
@State private var isCreatingTasks = false
@State private var showCustomTaskSheet = false
@State private var expandedCategory: String? = nil
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
@@ -161,7 +160,9 @@ struct OnboardingFirstTaskContent: View {
.offset(x: -15, y: -15)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
@@ -178,7 +179,9 @@ struct OnboardingFirstTaskContent: View {
.offset(x: 15, y: 15)
.scaleEffect(isAnimating ? 0.95 : 1.05)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
isAnimating
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
: .default,
value: isAnimating
)
@@ -341,6 +344,9 @@ struct OnboardingFirstTaskContent: View {
// Expand first category by default
expandedCategory = taskCategories.first?.name
}
.onDisappear {
isAnimating = false
}
}
private func selectPopularTasks() {
@@ -392,14 +398,12 @@ struct OnboardingFirstTaskContent: View {
for template in selectedTemplates {
// Look up category ID from DataManager
let categoryId: Int32? = {
let categoryName = template.category.lowercased()
return dataManager.taskCategories.first { $0.name.lowercased() == categoryName }?.id
return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id
}()
// Look up frequency ID from DataManager
let frequencyId: Int32? = {
let frequencyName = template.frequency.lowercased()
return dataManager.taskFrequencies.first { $0.name.lowercased() == frequencyName }?.id
return dataManager.taskFrequencies.first { $0.name.caseInsensitiveCompare(template.frequency) == .orderedSame }?.id
}()
print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))")
@@ -424,8 +428,10 @@ struct OnboardingFirstTaskContent: View {
print("🏠 ONBOARDING: Task '\(template.title)' creation: \(success ? "SUCCESS" : "FAILED") (\(completedCount)/\(totalCount))")
if completedCount == totalCount {
self.isCreatingTasks = false
self.onTaskAdded()
Task { @MainActor in
self.isCreatingTasks = false
self.onTaskAdded()
}
}
}
}

View File

@@ -81,7 +81,9 @@ struct OnboardingJoinResidenceContent: View {
.frame(width: 140, height: 140)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
@@ -222,6 +224,9 @@ struct OnboardingJoinResidenceContent: View {
isCodeFieldFocused = true
}
}
.onDisappear {
isAnimating = false
}
}
private func joinResidence() {

View File

@@ -6,7 +6,6 @@ struct OnboardingNameResidenceContent: View {
var onContinue: () -> Void
@FocusState private var isTextFieldFocused: Bool
@State private var showSuggestions = false
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
@@ -84,7 +83,9 @@ struct OnboardingNameResidenceContent: View {
.offset(x: -20, y: -20)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
@@ -101,7 +102,9 @@ struct OnboardingNameResidenceContent: View {
.offset(x: 20, y: 20)
.scaleEffect(isAnimating ? 0.95 : 1.05)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
isAnimating
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
: .default,
value: isAnimating
)
@@ -264,6 +267,9 @@ struct OnboardingNameResidenceContent: View {
isTextFieldFocused = true
}
}
.onDisappear {
isAnimating = false
}
}
}

View File

@@ -5,7 +5,7 @@ import StoreKit
struct OnboardingSubscriptionContent: View {
var onSubscribe: () -> Void
@StateObject private var storeKit = StoreKitManager.shared
@ObservedObject private var storeKit = StoreKitManager.shared
@State private var isLoading = false
@State private var purchaseError: String?
@State private var selectedPlan: PricingPlan = .yearly
@@ -109,7 +109,12 @@ struct OnboardingSubscriptionContent: View {
)
.frame(width: 180, height: 180)
.scaleEffect(animateBadge ? 1.1 : 1.0)
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge)
.animation(
animateBadge
? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)
: .default,
value: animateBadge
)
// Crown icon
ZStack {
@@ -210,6 +215,7 @@ struct OnboardingSubscriptionContent: View {
OrganicPricingPlanCard(
plan: .yearly,
isSelected: selectedPlan == .yearly,
displayPrice: yearlyProduct()?.displayPrice,
onSelect: { selectedPlan = .yearly }
)
@@ -217,6 +223,7 @@ struct OnboardingSubscriptionContent: View {
OrganicPricingPlanCard(
plan: .monthly,
isSelected: selectedPlan == .monthly,
displayPrice: monthlyProduct()?.displayPrice,
onSelect: { selectedPlan = .monthly }
)
}
@@ -277,7 +284,7 @@ struct OnboardingSubscriptionContent: View {
// Legal text
VStack(spacing: 4) {
Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")")
Text("7-day free trial, then \(productForSelectedPlan()?.displayPrice ?? selectedPlan.price)\(selectedPlan.period)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
@@ -296,6 +303,9 @@ struct OnboardingSubscriptionContent: View {
.onAppear {
animateBadge = true
}
.onDisappear {
animateBadge = false
}
.task {
if storeKit.products.isEmpty {
await storeKit.loadProducts()
@@ -338,8 +348,16 @@ struct OnboardingSubscriptionContent: View {
}
private func productForSelectedPlan() -> Product? {
let productIdHint = selectedPlan == .yearly ? "annual" : "monthly"
return storeKit.products.first { $0.id.localizedCaseInsensitiveContains(productIdHint) }
selectedPlan == .yearly ? yearlyProduct() : monthlyProduct()
}
private func yearlyProduct() -> Product? {
storeKit.products.first { $0.id.localizedCaseInsensitiveContains("annual") }
?? storeKit.products.first
}
private func monthlyProduct() -> Product? {
storeKit.products.first { $0.id.localizedCaseInsensitiveContains("monthly") }
?? storeKit.products.first
}
}
@@ -391,6 +409,7 @@ enum PricingPlan {
private struct OrganicPricingPlanCard: View {
let plan: PricingPlan
let isSelected: Bool
var displayPrice: String? = nil
var onSelect: () -> Void
@Environment(\.colorScheme) var colorScheme
@@ -444,7 +463,7 @@ private struct OrganicPricingPlanCard: View {
Spacer()
VStack(alignment: .trailing, spacing: 0) {
Text(plan.price)
Text(displayPrice ?? plan.price)
.font(.system(size: 20, weight: .bold))
.foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary)

View File

@@ -5,7 +5,6 @@ struct OnboardingValuePropsContent: View {
var onContinue: () -> Void
@State private var currentPage = 0
@State private var animateFeatures = false
private let features: [FeatureHighlight] = [
FeatureHighlight(

View File

@@ -75,7 +75,9 @@ struct OnboardingVerifyEmailContent: View {
.frame(width: 140, height: 140)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
@@ -133,12 +135,13 @@ struct OnboardingVerifyEmailContent: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
.keyboardDismissToolbar()
.onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
// Filter to digits only and truncate to 6 in one pass to prevent re-triggering
let filtered = String(newValue.filter { $0.isNumber }.prefix(6))
if filtered != newValue {
viewModel.code = filtered
}
// Auto-verify when 6 digits entered
if newValue.count == 6 {
if filtered.count == 6 {
viewModel.verifyEmail()
}
}
@@ -238,6 +241,9 @@ struct OnboardingVerifyEmailContent: View {
isCodeFieldFocused = true
}
}
.onDisappear {
isAnimating = false
}
.onReceive(viewModel.$isVerified) { isVerified in
print("🏠 ONBOARDING: onReceive isVerified = \(isVerified), hasCalledOnVerified = \(hasCalledOnVerified)")
if isVerified && !hasCalledOnVerified {

View File

@@ -76,7 +76,9 @@ struct OnboardingWelcomeView: View {
.frame(width: 200, height: 200)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
isAnimating
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
: .default,
value: isAnimating
)
@@ -178,10 +180,6 @@ struct OnboardingWelcomeView: View {
.padding(.bottom, 20)
}
// Deterministic marker for UI tests.
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
}
.sheet(isPresented: $showingLoginSheet) {
LoginView(onLoginSuccess: {
@@ -196,6 +194,9 @@ struct OnboardingWelcomeView: View {
iconOpacity = 1.0
}
}
.onDisappear {
isAnimating = false
}
}
}

View File

@@ -6,7 +6,7 @@ struct ForgotPasswordView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()

View File

@@ -26,11 +26,12 @@ struct PasswordResetFlow: View {
.animation(.easeInOut, value: viewModel.currentStep)
.onAppear {
// Set up callback for auto-login success
viewModel.onLoginSuccess = { [self] isVerified in
// Dismiss the sheet first
dismiss()
// Then call the parent's login success handler
onLoginSuccess?(isVerified)
// Capture dismiss and onLoginSuccess directly to avoid holding the entire view struct
let dismissAction = dismiss
let loginHandler = onLoginSuccess
viewModel.onLoginSuccess = { isVerified in
dismissAction()
loginHandler?(isVerified)
}
}
}

View File

@@ -28,6 +28,9 @@ class PasswordResetViewModel: ObservableObject {
// Callback for successful login after password reset
var onLoginSuccess: ((Bool) -> Void)?
// Cancellable delayed transition task
private var delayedTransitionTask: Task<Void, Never>?
// MARK: - Initialization
init(resetToken: String? = nil) {
// If we have a reset token from deep link, skip to password reset step
@@ -59,7 +62,10 @@ class PasswordResetViewModel: ObservableObject {
self.successMessage = "Check your email for a 6-digit verification code"
// Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.delayedTransitionTask?.cancel()
self.delayedTransitionTask = Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled else { return }
self.successMessage = nil
self.currentStep = .verifyCode
}
@@ -99,7 +105,10 @@ class PasswordResetViewModel: ObservableObject {
self.successMessage = "Code verified! Now set your new password"
// Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.delayedTransitionTask?.cancel()
self.delayedTransitionTask = Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled else { return }
self.successMessage = nil
self.currentStep = .resetPassword
}
@@ -191,8 +200,8 @@ class PasswordResetViewModel: ObservableObject {
let response = success.data {
let isVerified = response.user.verified
// Initialize lookups
_ = try? await APILayer.shared.initializeLookups()
// Lookups are already initialized by APILayer.login() internally
// (see APILayer.kt line 1205) no need to call again here
self.isLoading = false
@@ -200,7 +209,9 @@ class PasswordResetViewModel: ObservableObject {
self.onLoginSuccess?(isVerified)
} else if let error = ApiResultBridge.error(from: loginResult) {
// Auto-login failed, fall back to manual login
#if DEBUG
print("Auto-login failed: \(error.message)")
#endif
self.isLoading = false
self.successMessage = "Password reset successfully! You can now log in with your new password."
self.currentStep = .success
@@ -211,7 +222,9 @@ class PasswordResetViewModel: ObservableObject {
}
} catch {
// Auto-login failed, fall back to manual login
#if DEBUG
print("Auto-login error: \(error.localizedDescription)")
#endif
self.isLoading = false
self.successMessage = "Password reset successfully! You can now log in with your new password."
self.currentStep = .success
@@ -250,6 +263,8 @@ class PasswordResetViewModel: ObservableObject {
/// Reset all state
func reset() {
delayedTransitionTask?.cancel()
delayedTransitionTask = nil
email = ""
code = ""
newPassword = ""
@@ -261,6 +276,10 @@ class PasswordResetViewModel: ObservableObject {
isLoading = false
}
deinit {
delayedTransitionTask?.cancel()
}
func clearError() {
errorMessage = nil
}

View File

@@ -35,7 +35,7 @@ struct ResetPasswordView: View {
}
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()

View File

@@ -6,7 +6,7 @@ struct VerifyResetCodeView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()
@@ -98,10 +98,10 @@ struct VerifyResetCodeView: View {
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.onChange(of: viewModel.code) { _, newValue in
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
let filtered = String(newValue.filter { $0.isNumber }.prefix(6))
if filtered != newValue {
viewModel.code = filtered
}
viewModel.code = newValue.filter { $0.isNumber }
viewModel.clearError()
}

View File

@@ -3,8 +3,11 @@ import SwiftUI
struct AnimationTestingView: View {
@Environment(\.dismiss) private var dismiss
// Animation selection
@State private var selectedAnimation: TaskAnimationType = .implode
// Animation selection (persisted)
@StateObject private var animationPreference = AnimationPreference.shared
private var selectedAnimation: TaskAnimationType {
get { animationPreference.selectedAnimation }
}
// Fake task data
@State private var columns: [TestColumn] = TestColumn.defaultColumns
@@ -30,7 +33,7 @@ struct AnimationTestingView: View {
resetButton
}
}
.navigationTitle("Animation Testing")
.navigationTitle("Completion Animation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
@@ -59,13 +62,13 @@ struct AnimationTestingView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.xs) {
ForEach(TaskAnimationType.allCases) { animation in
ForEach(TaskAnimationType.selectableCases) { animation in
AnimationChip(
animation: animation,
isSelected: selectedAnimation == animation,
onSelect: {
withAnimation(.easeInOut(duration: 0.2)) {
selectedAnimation = animation
animationPreference.selectedAnimation = animation
}
}
)
@@ -135,6 +138,17 @@ struct AnimationTestingView: View {
animatingTaskId = task.id
// No animation: instant move
if selectedAnimation == .none {
if let taskIndex = columns[currentIndex].tasks.firstIndex(where: { $0.id == task.id }) {
columns[currentIndex].tasks.remove(at: taskIndex)
}
columns[currentIndex + 1].tasks.insert(task, at: 0)
animatingTaskId = nil
animationPhase = .idle
return
}
// Extended timing animations: shrink card, show checkmark, THEN move task
if selectedAnimation.needsExtendedTiming {
// Phase 1: Start shrinking

View File

@@ -3,6 +3,7 @@ import SwiftUI
// MARK: - Animation Type Enum
enum TaskAnimationType: String, CaseIterable, Identifiable {
case none = "None"
case implode = "Implode"
case firework = "Firework"
case starburst = "Starburst"
@@ -12,6 +13,7 @@ enum TaskAnimationType: String, CaseIterable, Identifiable {
var icon: String {
switch self {
case .none: return "minus.circle"
case .implode: return "checkmark.circle"
case .firework: return "sparkle"
case .starburst: return "sun.max.fill"
@@ -21,6 +23,7 @@ enum TaskAnimationType: String, CaseIterable, Identifiable {
var description: String {
switch self {
case .none: return "No animation, instant move"
case .implode: return "Sucks into center, becomes checkmark"
case .firework: return "Explodes into colorful sparks"
case .starburst: return "Radiating rays from checkmark"
@@ -29,7 +32,17 @@ enum TaskAnimationType: String, CaseIterable, Identifiable {
}
/// All celebration animations need extended timing for checkmark display
var needsExtendedTiming: Bool { true }
var needsExtendedTiming: Bool {
switch self {
case .none: return false
default: return true
}
}
/// Selectable animation types (excludes "none" from picker in testing view)
static var selectableCases: [TaskAnimationType] {
allCases
}
}
// MARK: - Animation Phase
@@ -159,6 +172,8 @@ extension View {
@ViewBuilder
func taskAnimation(type: TaskAnimationType, phase: AnimationPhase) -> some View {
switch type {
case .none:
self
case .implode:
self.implodeAnimation(phase: phase)
case .firework:

View File

@@ -4,6 +4,7 @@ import ComposeApp
struct NotificationPreferencesView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = NotificationPreferencesViewModelWrapper()
@State private var isInitialLoad = true
var body: some View {
NavigationStack {
@@ -96,6 +97,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskDueSoon) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskDueSoon: newValue)
}
@@ -130,6 +132,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskOverdue) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskOverdue: newValue)
}
@@ -164,6 +167,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskCompleted) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskCompleted: newValue)
}
@@ -183,6 +187,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskAssigned) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskAssigned: newValue)
}
} header: {
@@ -216,6 +221,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.residenceShared) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(residenceShared: newValue)
}
@@ -235,6 +241,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.warrantyExpiring) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(warrantyExpiring: newValue)
}
@@ -254,6 +261,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.dailyDigest) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(dailyDigest: newValue)
}
@@ -294,6 +302,7 @@ struct NotificationPreferencesView: View {
}
.tint(Color.appPrimary)
.onChange(of: viewModel.emailTaskCompleted) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(emailTaskCompleted: newValue)
}
} header: {
@@ -323,6 +332,12 @@ struct NotificationPreferencesView: View {
AnalyticsManager.shared.trackScreen(.notificationSettings)
viewModel.loadPreferences()
}
.onChange(of: viewModel.isLoading) { _, newValue in
// Clear the initial load guard once preferences have finished loading
if !newValue && isInitialLoad {
isInitialLoad = false
}
}
}
}

View File

@@ -12,6 +12,7 @@ struct ProfileTabView: View {
@State private var showRestoreSuccess = false
@State private var showingNotificationPreferences = false
@State private var showingAnimationTesting = false
@StateObject private var animationPreference = AnimationPreference.shared
var body: some View {
List {
@@ -66,6 +67,19 @@ struct ProfileTabView: View {
// Subscription Section - Only show if limitations are enabled on backend
if let subscription = subscriptionCache.currentSubscription, subscription.limitationsEnabled {
Section(L10n.Profile.subscription) {
// Trial banner
if subscription.trialActive, let trialEnd = subscription.trialEnd {
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appAccent)
}
.padding(.vertical, 6)
}
HStack {
Image(systemName: "crown.fill")
.foregroundColor(subscriptionCache.currentTier == "pro" ? Color.appAccent : Color.appTextSecondary)
@@ -80,7 +94,7 @@ struct ProfileTabView: View {
Text("\(L10n.Profile.activeUntil) \(DateUtils.formatDateMedium(expiresAt))")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
} else {
} else if !subscription.trialActive {
Text(L10n.Profile.limitedFeatures)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
@@ -112,20 +126,44 @@ struct ProfileTabView: View {
.foregroundColor(Color.appPrimary)
}
} else {
Button(action: {
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
UIApplication.shared.open(url)
// Subscription management varies by source platform
if subscription.subscriptionSource == "stripe" {
// Web/Stripe subscription - direct to web portal
Button(action: {
if let url = URL(string: "https://casera.app/settings") {
UIApplication.shared.open(url)
}
}) {
Label("Manage at casera.app", systemImage: "globe")
.foregroundColor(Color.appTextPrimary)
}
} else if subscription.subscriptionSource == "android" {
// Android subscription - informational only
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(Color.appTextSecondary)
Text("Manage your subscription on your Android device")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(.vertical, 4)
} else {
// iOS subscription (source is "ios" or nil) - normal StoreKit management
Button(action: {
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
UIApplication.shared.open(url)
}
}) {
Label(L10n.Profile.manageSubscription, systemImage: "gearshape.fill")
.foregroundColor(Color.appTextPrimary)
}
}) {
Label(L10n.Profile.manageSubscription, systemImage: "gearshape.fill")
.foregroundColor(Color.appTextPrimary)
}
}
Button(action: {
Task {
await storeKitManager.restorePurchases()
showRestoreSuccess = true
showRestoreSuccess = !storeKitManager.purchasedProductIDs.isEmpty
}
}) {
Label(L10n.Profile.restorePurchases, systemImage: "arrow.clockwise")
@@ -159,11 +197,15 @@ struct ProfileTabView: View {
showingAnimationTesting = true
}) {
HStack {
Label("Animation Testing", systemImage: "sparkles.rectangle.stack")
Label("Completion Animation", systemImage: "sparkles.rectangle.stack")
.foregroundColor(Color.appTextPrimary)
Spacer()
Text(animationPreference.selectedAnimation.rawValue)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)

View File

@@ -10,7 +10,7 @@ struct ProfileView: View {
}
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()
.ignoresSafeArea()

View File

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

View File

@@ -21,7 +21,7 @@ struct RegisterView: View {
}
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()

View File

@@ -10,7 +10,7 @@ struct JoinResidenceView: View {
@FocusState private var isCodeFocused: Bool
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()

View File

@@ -16,10 +16,10 @@ struct ManageUsersView: View {
@State private var errorMessage: String?
@State private var isGeneratingCode = false
@State private var shareFileURL: URL?
@StateObject private var sharingManager = ResidenceSharingManager.shared
@ObservedObject private var sharingManager = ResidenceSharingManager.shared
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()

View File

@@ -28,6 +28,13 @@ struct ResidenceDetailView: View {
@State private var selectedTaskForCancel: TaskResponse?
@State private var showCancelConfirmation = false
// Completion animation state
@StateObject private var animationPreference = AnimationPreference.shared
@State private var animatingTaskId: Int32? = nil
@State private var animationPhase: AnimationPhase = .idle
@State private var pendingCompletedTask: TaskResponse? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var hasAppeared = false
@State private var showReportAlert = false
@State private var showReportConfirmation = false
@@ -105,14 +112,17 @@ struct ResidenceDetailView: View {
EditTaskView(task: task, isPresented: $showEditTask)
}
}
.sheet(item: $selectedTaskForComplete) { task in
.sheet(item: $selectedTaskForComplete, onDismiss: {
if let task = pendingCompletedTask {
startCompletionAnimation(for: task)
} else {
taskViewModel.isAnimatingCompletion = false
loadResidenceTasks(forceRefresh: true)
}
}) { task in
CompleteTaskView(task: task) { updatedTask in
print("DEBUG: onComplete callback called")
print("DEBUG: updatedTask is nil: \(updatedTask == nil)")
if let updatedTask = updatedTask {
print("DEBUG: updatedTask.id = \(updatedTask.id)")
print("DEBUG: updatedTask.kanbanColumn = \(updatedTask.kanbanColumn ?? "nil")")
updateTaskInKanban(updatedTask)
pendingCompletedTask = updatedTask
}
selectedTaskForComplete = nil
}
@@ -248,6 +258,9 @@ private extension ResidenceDetailView {
showArchiveConfirmation: $showArchiveConfirmation,
selectedTaskForCancel: $selectedTaskForCancel,
showCancelConfirmation: $showCancelConfirmation,
animatingTaskId: animatingTaskId,
animationPhase: animationPhase,
animationType: animationPreference.selectedAnimation,
reloadTasks: { loadResidenceTasks(forceRefresh: true) }
)
} else if isLoadingTasks {
@@ -422,6 +435,37 @@ private extension ResidenceDetailView {
taskViewModel.updateTaskInKanban(updatedTask)
}
func startCompletionAnimation(for updatedTask: TaskResponse) {
let duration = animationPreference.animationDuration(reduceMotion: reduceMotion)
guard duration > 0 else {
taskViewModel.isAnimatingCompletion = false
updateTaskInKanban(updatedTask)
pendingCompletedTask = nil
return
}
animatingTaskId = updatedTask.id
withAnimation {
animationPhase = .exiting
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation {
animationPhase = .complete
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
taskViewModel.isAnimatingCompletion = false
updateTaskInKanban(updatedTask)
animatingTaskId = nil
animationPhase = .idle
pendingCompletedTask = nil
}
}
func deleteResidence() {
guard TokenStorage.shared.getToken() != nil else { return }
@@ -500,6 +544,11 @@ private struct TasksSectionContainer: View {
@Binding var selectedTaskForCancel: TaskResponse?
@Binding var showCancelConfirmation: Bool
// Completion animation state
var animatingTaskId: Int32? = nil
var animationPhase: AnimationPhase = .idle
var animationType: TaskAnimationType = .none
let reloadTasks: () -> Void
var body: some View {
@@ -526,6 +575,7 @@ private struct TasksSectionContainer: View {
}
},
onCompleteTask: { task in
taskViewModel.isAnimatingCompletion = true
selectedTaskForComplete = task
},
onArchiveTask: { task in
@@ -536,7 +586,10 @@ private struct TasksSectionContainer: View {
taskViewModel.unarchiveTask(id: taskId) { _ in
reloadTasks()
}
}
},
animatingTaskId: animatingTaskId,
animationPhase: animationPhase,
animationType: animationType
)
}
}

View File

@@ -66,7 +66,9 @@ class ResidenceSharingManager: ObservableObject {
let jsonContent = CaseraShareCodec.shared.encodeSharedResidence(sharedResidence: sharedResidence)
guard let jsonData = jsonContent.data(using: .utf8) else {
#if DEBUG
print("ResidenceSharingManager: Failed to encode residence package as UTF-8")
#endif
errorMessage = "Failed to create share file"
return nil
}
@@ -80,7 +82,9 @@ class ResidenceSharingManager: ObservableObject {
AnalyticsManager.shared.track(.residenceShared(method: "file"))
return tempURL
} catch {
#if DEBUG
print("ResidenceSharingManager: Failed to write .casera file: \(error)")
#endif
errorMessage = "Failed to save share file"
return nil
}

View File

@@ -197,35 +197,27 @@ class ResidenceViewModel: ObservableObject {
Task {
do {
print("🏠 ResidenceVM: Calling API...")
let result = try await APILayer.shared.createResidence(request: request)
print("🏠 ResidenceVM: Got result: \(String(describing: result))")
await MainActor.run {
if let success = result as? ApiResultSuccess<ResidenceResponse> {
print("🏠 ResidenceVM: Is ApiResultSuccess, data = \(String(describing: success.data))")
if let residence = success.data {
print("🏠 ResidenceVM: Got residence with id \(residence.id)")
self.isLoading = false
completion(residence)
} else {
print("🏠 ResidenceVM: success.data is nil")
self.isLoading = false
completion(nil)
}
} else if let error = ApiResultBridge.error(from: result) {
print("🏠 ResidenceVM: Is ApiResultError: \(error.message ?? "nil")")
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(nil)
} else {
print("🏠 ResidenceVM: Unknown result type: \(type(of: result))")
self.isLoading = false
completion(nil)
}
}
} catch {
print("🏠 ResidenceVM: Exception: \(error)")
await MainActor.run {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false

View File

@@ -59,7 +59,7 @@ struct ResidenceFormView: View {
}
var body: some View {
NavigationView {
NavigationStack {
ZStack {
WarmGradientBackground()
@@ -357,11 +357,11 @@ struct ResidenceFormView: View {
stateProvince = residence.stateProvince ?? ""
postalCode = residence.postalCode ?? ""
country = residence.country ?? ""
bedrooms = residence.bedrooms != nil ? "\(residence.bedrooms!)" : ""
bathrooms = residence.bathrooms != nil ? "\(residence.bathrooms!)" : ""
squareFootage = residence.squareFootage != nil ? "\(residence.squareFootage!)" : ""
lotSize = residence.lotSize != nil ? "\(residence.lotSize!)" : ""
yearBuilt = residence.yearBuilt != nil ? "\(residence.yearBuilt!)" : ""
bedrooms = residence.bedrooms.map { "\($0)" } ?? ""
bathrooms = residence.bathrooms.map { "\($0)" } ?? ""
squareFootage = residence.squareFootage.map { "\($0)" } ?? ""
lotSize = residence.lotSize.map { "\($0)" } ?? ""
yearBuilt = residence.yearBuilt.map { "\($0)" } ?? ""
description = residence.description_ ?? ""
isPrimary = residence.isPrimary

View File

@@ -2,6 +2,7 @@ import SwiftUI
import ComposeApp
/// Shared authentication state manager
@MainActor
class AuthenticationManager: ObservableObject {
static let shared = AuthenticationManager()
@@ -33,14 +34,11 @@ class AuthenticationManager: ObservableObject {
isAuthenticated = true
// Fetch current user and initialize lookups immediately for all authenticated users
// Fetch current user to validate token and check verification status
Task { @MainActor in
do {
// Initialize lookups right away for any authenticated user
// This fetches /static_data/ and /upgrade-triggers/ at app start
print("🚀 Initializing lookups at app start...")
_ = try await APILayer.shared.initializeLookups()
print("✅ Lookups initialized on app launch")
// Lookups are already initialized by iOSApp.init() at startup
// and refreshed by scenePhase .active handler no need to call again here
let result = try await APILayer.shared.getCurrentUser(forceRefresh: true)
@@ -61,11 +59,22 @@ class AuthenticationManager: ObservableObject {
self.isVerified = false
}
} catch {
print("❌ Failed to check auth status: \(error)")
// On error, assume token is invalid
DataManager.shared.clear()
self.isAuthenticated = false
self.isVerified = false
#if DEBUG
print("Failed to check auth status: \(error)")
#endif
// Distinguish network errors from auth errors
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
// Network error keep authenticated state, user may be offline
#if DEBUG
print("Network error during auth check, keeping auth state")
#endif
} else {
// Auth error token is invalid
DataManager.shared.clear()
self.isAuthenticated = false
self.isVerified = false
}
}
self.isCheckingAuth = false
@@ -105,6 +114,9 @@ class AuthenticationManager: ObservableObject {
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
// Clear authenticated image cache
AuthenticatedImage.clearCache()
// Update authentication state
isAuthenticated = false
isVerified = false
@@ -112,7 +124,9 @@ class AuthenticationManager: ObservableObject {
// Note: We don't reset onboarding state on logout
// so returning users go to login screen, not onboarding
#if DEBUG
print("AuthenticationManager: Logged out - all state reset")
#endif
}
/// Reset onboarding state (for testing or re-onboarding)
@@ -127,6 +141,7 @@ struct RootView: View {
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var onboardingState = OnboardingState.shared
@State private var refreshID = UUID()
@Binding var deepLinkResetToken: String?
var body: some View {
ZStack(alignment: .topLeading) {
@@ -151,7 +166,7 @@ struct RootView: View {
} else if !authManager.isAuthenticated {
// Show login screen for returning users
ZStack(alignment: .topLeading) {
LoginView()
LoginView(resetToken: $deepLinkResetToken)
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("ui.root.login")

View File

@@ -96,6 +96,7 @@ class DateFormatters {
lazy var mediumDate: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
@@ -103,6 +104,7 @@ class DateFormatters {
lazy var longDate: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM d, yyyy"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
@@ -110,6 +112,7 @@ class DateFormatters {
lazy var shortDate: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yyyy"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
@@ -117,6 +120,7 @@ class DateFormatters {
lazy var apiDate: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
@@ -124,6 +128,7 @@ class DateFormatters {
lazy var time: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm a"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
@@ -131,6 +136,7 @@ class DateFormatters {
lazy var dateTime: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy 'at' h:mm a"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
}

View File

@@ -0,0 +1,29 @@
import SwiftUI
/// Persists the user's selected task completion animation type.
/// Observed by task views to determine which animation to play after completing a task.
final class AnimationPreference: ObservableObject {
static let shared = AnimationPreference()
@AppStorage("selectedTaskAnimation") private var storedValue: String = TaskAnimationType.implode.rawValue
/// The currently selected animation type, persisted across launches.
var selectedAnimation: TaskAnimationType {
get { TaskAnimationType(rawValue: storedValue) ?? .implode }
set {
storedValue = newValue.rawValue
objectWillChange.send()
}
}
/// Duration to wait for the celebration animation before moving the task.
/// Returns 0 for `.none` or when Reduce Motion is enabled.
func animationDuration(reduceMotion: Bool) -> Double {
if reduceMotion || selectedAnimation == .none {
return 0
}
return 2.2
}
private init() {}
}

View File

@@ -1,4 +0,0 @@
import Foundation
import ComposeApp
import Combine

View File

@@ -12,22 +12,53 @@ struct FeatureComparisonView: View {
@State private var errorMessage: String?
@State private var showSuccessAlert = false
/// Whether the user is already subscribed from a non-iOS platform
private var isSubscribedOnOtherPlatform: Bool {
guard let subscription = subscriptionCache.currentSubscription,
subscriptionCache.currentTier == "pro",
let source = subscription.subscriptionSource,
source != "ios" else {
return false
}
return true
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Trial banner
if let subscription = subscriptionCache.currentSubscription,
subscription.trialActive,
let trialEnd = subscription.trialEnd {
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appAccent)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.appAccent.opacity(0.1))
.cornerRadius(AppRadius.md)
.padding(.horizontal)
.padding(.top, AppSpacing.lg)
}
// Header
VStack(spacing: AppSpacing.sm) {
Text("Choose Your Plan")
.font(.title.weight(.bold))
.foregroundColor(Color.appTextPrimary)
Text("Upgrade to Pro for unlimited access")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.padding(.top, AppSpacing.lg)
// Feature Comparison Table
VStack(spacing: 0) {
// Header Row
@@ -78,7 +109,13 @@ struct FeatureComparisonView: View {
.padding(.horizontal)
// Subscription Products
if storeKit.isLoading {
if isSubscribedOnOtherPlatform {
// User is subscribed on another platform
CrossPlatformSubscriptionNotice(
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
)
.padding(.horizontal)
} else if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
@@ -129,14 +166,16 @@ struct FeatureComparisonView: View {
}
// Restore Purchases
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
if !isSubscribedOnOtherPlatform {
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(.bottom, AppSpacing.xl)
}
.padding(.bottom, AppSpacing.xl)
}
}
.background(WarmGradientBackground())
@@ -216,7 +255,7 @@ struct SubscriptionButton: View {
let onSelect: () -> Void
var isAnnual: Bool {
product.id.contains("annual")
product.subscription?.subscriptionPeriod.unit == .year
}
var savingsText: String? {
@@ -293,6 +332,58 @@ struct ComparisonRow: View {
}
}
// MARK: - Cross-Platform Subscription Notice
struct CrossPlatformSubscriptionNotice: View {
let source: String
var body: some View {
VStack(spacing: AppSpacing.md) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 36))
.foregroundColor(Color.appPrimary)
Text("You're already subscribed")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
if source == "stripe" {
Text("Manage your subscription at casera.app")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
Button(action: {
if let url = URL(string: "https://casera.app/settings") {
UIApplication.shared.open(url)
}
}) {
Label("Open casera.app", systemImage: "globe")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppRadius.md)
}
} else if source == "android" {
Text("Your subscription is managed through Google Play on your Android device.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
} else {
Text("Your subscription is managed on another platform.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
.padding()
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
}
}
#Preview {
FeatureComparisonView(isPresented: .constant(true))
}

View File

@@ -22,6 +22,11 @@ class SubscriptionCacheWrapper: ObservableObject {
/// Current tier derived from backend subscription status, with StoreKit fallback.
/// Mirrors the logic in Kotlin SubscriptionHelper.currentTier.
var currentTier: String {
// Active trial grants pro access.
if let subscription = currentSubscription, subscription.trialActive {
return "pro"
}
// Prefer backend subscription state when available.
// `expiresAt` is only expected for active paid plans.
if let subscription = currentSubscription,

View File

@@ -31,9 +31,39 @@ struct UpgradeFeatureView: View {
triggerData?.buttonText ?? "Upgrade to Pro"
}
/// Whether the user is already subscribed from a non-iOS platform
private var isSubscribedOnOtherPlatform: Bool {
guard let subscription = subscriptionCache.currentSubscription,
subscriptionCache.currentTier == "pro",
let source = subscription.subscriptionSource,
source != "ios" else {
return false
}
return true
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Trial banner
if let subscription = subscriptionCache.currentSubscription,
subscription.trialActive,
let trialEnd = subscription.trialEnd {
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appAccent)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.appAccent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
@@ -68,7 +98,7 @@ struct UpgradeFeatureView: View {
)
.frame(width: 80, height: 80)
Image(systemName: "star.fill")
Image(systemName: icon)
.font(.system(size: 36, weight: .medium))
.foregroundColor(.white)
}
@@ -110,41 +140,48 @@ struct UpgradeFeatureView: View {
.padding(.horizontal, 16)
// Subscription Products
VStack(spacing: 12) {
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
}
} else {
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Retry Loading Products")
.font(.system(size: 16, weight: .semibold))
if isSubscribedOnOtherPlatform {
CrossPlatformSubscriptionNotice(
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
)
.padding(.horizontal, 16)
} else {
VStack(spacing: 12) {
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
}
} else {
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Retry Loading Products")
.font(.system(size: 16, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
.padding(.horizontal, 16)
}
.padding(.horizontal, 16)
// Error Message
if let error = errorMessage {
@@ -172,12 +209,14 @@ struct UpgradeFeatureView: View {
.foregroundColor(Color.appPrimary)
}
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
if !isSubscribedOnOtherPlatform {
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(.bottom, OrganicSpacing.airy)

View File

@@ -135,6 +135,17 @@ struct UpgradePromptView: View {
subscriptionCache.upgradeTriggers[triggerKey]
}
/// Whether the user is already subscribed from a non-iOS platform
private var isSubscribedOnOtherPlatform: Bool {
guard let subscription = subscriptionCache.currentSubscription,
subscriptionCache.currentTier == "pro",
let source = subscription.subscriptionSource,
source != "ios" else {
return false
}
return true
}
var body: some View {
NavigationStack {
ZStack {
@@ -142,6 +153,25 @@ struct UpgradePromptView: View {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Trial banner
if let subscription = subscriptionCache.currentSubscription,
subscription.trialActive,
let trialEnd = subscription.trialEnd {
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appAccent)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.appAccent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
@@ -218,41 +248,48 @@ struct UpgradePromptView: View {
.padding(.horizontal, 16)
// Subscription Products
VStack(spacing: 12) {
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
ForEach(storeKit.products, id: \.id) { product in
OrganicSubscriptionButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
}
} else {
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Retry Loading Products")
.font(.system(size: 16, weight: .semibold))
if isSubscribedOnOtherPlatform {
CrossPlatformSubscriptionNotice(
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
)
.padding(.horizontal, 16)
} else {
VStack(spacing: 12) {
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
ForEach(storeKit.products, id: \.id) { product in
OrganicSubscriptionButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
}
} else {
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Retry Loading Products")
.font(.system(size: 16, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
.padding(.horizontal, 16)
}
.padding(.horizontal, 16)
// Error Message
if let error = errorMessage {
@@ -280,12 +317,14 @@ struct UpgradePromptView: View {
.foregroundColor(Color.appPrimary)
}
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
if !isSubscribedOnOtherPlatform {
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(.bottom, OrganicSpacing.airy)

View File

@@ -6,7 +6,7 @@ struct CameraPickerView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary
picker.delegate = context.coordinator
return picker
}

View File

@@ -1,2 +0,0 @@
import SwiftUI
import ComposeApp

View File

@@ -186,7 +186,9 @@ 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()
@@ -195,14 +197,21 @@ private struct PulseRing: View {
.scaleEffect(isPulsing ? 1.15 : 1.0)
.opacity(isPulsing ? 0 : 1)
.animation(
Animation
.easeOut(duration: 1.5)
.repeatForever(autoreverses: false),
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
}
}
}

View File

@@ -12,6 +12,11 @@ struct DynamicTaskColumnView: View {
let onArchiveTask: (TaskResponse) -> Void
let onUnarchiveTask: (Int32) -> Void
// Completion animation state (passed from parent)
var animatingTaskId: Int32? = nil
var animationPhase: AnimationPhase = .idle
var animationType: TaskAnimationType = .none
// Get icon from API response, with fallback
private var columnIcon: String {
column.icons["ios"] ?? "list.bullet"
@@ -71,6 +76,10 @@ struct DynamicTaskColumnView: View {
onArchive: { onArchiveTask(task) },
onUnarchive: { onUnarchiveTask(task.id) }
)
.taskAnimation(
type: animationType,
phase: animatingTaskId == task.id ? animationPhase : .idle
)
}
}
}

View File

@@ -7,7 +7,7 @@ struct PhotoViewerSheet: View {
@State private var selectedImage: TaskCompletionImage?
var body: some View {
NavigationView {
NavigationStack {
Group {
if let selectedImage = selectedImage {
// Single image view

View File

@@ -1,6 +1,9 @@
import SwiftUI
import ComposeApp
// TODO: (P5) Each action button that performs an API call creates its own @StateObject TaskViewModel instance.
// This is potentially wasteful consider accepting a shared TaskViewModel from the parent view instead.
// MARK: - Edit Task Button
struct EditTaskButton: View {
let taskId: Int32

View File

@@ -11,6 +11,11 @@ struct TasksSection: View {
let onArchiveTask: (TaskResponse) -> Void
let onUnarchiveTask: (Int32) -> Void
// Completion animation state (passed from parent)
var animatingTaskId: Int32? = nil
var animationPhase: AnimationPhase = .idle
var animationType: TaskAnimationType = .none
private var hasNoTasks: Bool {
tasksResponse.columns.allSatisfy { $0.tasks.isEmpty }
}
@@ -58,7 +63,10 @@ struct TasksSection: View {
},
onUnarchiveTask: { taskId in
onUnarchiveTask(taskId)
}
},
animatingTaskId: animatingTaskId,
animationPhase: animationPhase,
animationType: animationType
)
// Show swipe hint on first column when it's empty but others have tasks

View File

@@ -21,6 +21,13 @@ struct AllTasksView: View {
@State private var pendingTaskId: Int32?
@State private var scrollToColumnIndex: Int?
// Completion animation state
@StateObject private var animationPreference = AnimationPreference.shared
@State private var animatingTaskId: Int32? = nil
@State private var animationPhase: AnimationPhase = .idle
@State private var pendingCompletedTask: TaskResponse? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
private var hasTasks: Bool { taskViewModel.hasTasks }
@@ -48,10 +55,17 @@ struct AllTasksView: View {
EditTaskView(task: task, isPresented: $showEditTask)
}
}
.sheet(item: $selectedTaskForComplete) { task in
.sheet(item: $selectedTaskForComplete, onDismiss: {
if let task = pendingCompletedTask {
startCompletionAnimation(for: task)
} else {
taskViewModel.isAnimatingCompletion = false
loadAllTasks(forceRefresh: true)
}
}) { task in
CompleteTaskView(task: task) { updatedTask in
if let updatedTask = updatedTask {
updateTaskInKanban(updatedTask)
pendingCompletedTask = updatedTask
}
selectedTaskForComplete = nil
}
@@ -190,6 +204,9 @@ struct AllTasksView: View {
}
},
onCompleteTask: { task in
// Block DataManager BEFORE sheet opens so the
// API response can't move the task while we wait
taskViewModel.isAnimatingCompletion = true
selectedTaskForComplete = task
},
onArchiveTask: { task in
@@ -200,7 +217,10 @@ struct AllTasksView: View {
taskViewModel.unarchiveTask(id: taskId) { _ in
loadAllTasks()
}
}
},
animatingTaskId: animatingTaskId,
animationPhase: animationPhase,
animationType: animationPreference.selectedAnimation
)
if index == 0 && shouldShowSwipeHint {
@@ -273,6 +293,39 @@ struct AllTasksView: View {
taskViewModel.updateTaskInKanban(updatedTask)
}
/// Called after the completion sheet is fully dismissed.
/// Plays the celebration animation on the card, then moves it to Done.
private func startCompletionAnimation(for updatedTask: TaskResponse) {
let duration = animationPreference.animationDuration(reduceMotion: reduceMotion)
guard duration > 0 else {
taskViewModel.isAnimatingCompletion = false
updateTaskInKanban(updatedTask)
pendingCompletedTask = nil
return
}
animatingTaskId = updatedTask.id
withAnimation {
animationPhase = .exiting
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation {
animationPhase = .complete
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
taskViewModel.isAnimatingCompletion = false
updateTaskInKanban(updatedTask)
animatingTaskId = nil
animationPhase = .idle
pendingCompletedTask = nil
}
}
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
for (index, column) in response.columns.enumerated() {
if column.tasks.contains(where: { $0.id == taskId }) {

View File

@@ -22,6 +22,7 @@ struct CompleteTaskView: View {
@State private var showCamera: Bool = false
@State private var selectedContractor: ContractorSummary? = nil
@State private var showContractorPicker: Bool = false
@State private var observationTask: Task<Void, Never>? = nil
var body: some View {
NavigationStack {
@@ -293,6 +294,10 @@ struct CompleteTaskView: View {
.onAppear {
contractorViewModel.loadContractors()
}
.onDisappear {
observationTask?.cancel()
observationTask = nil
}
.handleErrors(
error: errorMessage,
onRetry: { handleComplete() }
@@ -333,9 +338,11 @@ struct CompleteTaskView: View {
completionViewModel.createTaskCompletion(request: request)
}
// Observe the result
Task {
// Observe the result store the Task so it can be cancelled on dismiss
observationTask?.cancel()
observationTask = Task {
for await state in completionViewModel.createCompletionState {
if Task.isCancelled { break }
await MainActor.run {
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
self.isSubmitting = false

View File

@@ -69,7 +69,7 @@ struct TaskFormView: View {
// Parse date from string - use effective due date (nextDueDate if set, otherwise dueDate)
_dueDate = State(initialValue: (task.effectiveDueDate ?? "").toDate() ?? Date())
_intervalDays = State(initialValue: task.customIntervalDays != nil ? String(task.customIntervalDays!.int32Value) : "")
_intervalDays = State(initialValue: task.customIntervalDays.map { "\($0.int32Value)" } ?? "")
_estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "")
} else {
_title = State(initialValue: "")
@@ -444,6 +444,11 @@ struct TaskFormView: View {
isValid = false
}
if !intervalDays.isEmpty, Int32(intervalDays) == nil {
viewModel.errorMessage = "Custom interval must be a valid number"
isValid = false
}
return isValid
}

View File

@@ -10,6 +10,10 @@ class TaskViewModel: ObservableObject {
// MARK: - Published Properties (from DataManager observation)
@Published var tasksResponse: TaskColumnsResponse?
/// When true, DataManager observation is paused to allow completion animation to play
/// without the task being moved out of its column prematurely.
var isAnimatingCompletion = false
// MARK: - Local State
@Published var actionState: ActionState<TaskActionType> = .idle
@Published var errorMessage: String?
@@ -42,6 +46,9 @@ class TaskViewModel: ObservableObject {
DataManagerObservable.shared.$allTasks
.receive(on: DispatchQueue.main)
.sink { [weak self] allTasks in
// Skip DataManager updates during completion animation to prevent
// the task from being moved out of its column before the animation finishes
guard self?.isAnimatingCompletion != true else { return }
// Only update if we're showing all tasks (no residence filter)
if self?.currentResidenceId == nil {
self?.tasksResponse = allTasks
@@ -56,6 +63,7 @@ class TaskViewModel: ObservableObject {
DataManagerObservable.shared.$tasksByResidence
.receive(on: DispatchQueue.main)
.sink { [weak self] tasksByResidence in
guard self?.isAnimatingCompletion != true else { return }
// Only update if we're filtering by residence
if let resId = self?.currentResidenceId,
let tasks = tasksByResidence[resId] {