Harden iOS app with audit fixes, UI consistency, and sheet race condition fixes

Applies verified fixes from deep audit (concurrency, performance, security,
accessibility), standardizes CRUD form buttons to Add/Save pattern, removes
.drawingGroup() that broke search bar TextFields, and converts vulnerable
.sheet(isPresented:) + if-let patterns to safe presentation to prevent
blank white modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-06 09:59:56 -06:00
parent 61ab95d108
commit 9c574c4343
76 changed files with 824 additions and 971 deletions

View File

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

View File

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

View File

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

View File

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

View File

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