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:
@@ -77,7 +77,7 @@ struct JoinResidenceView: View {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||
)
|
||||
.onChange(of: shareCode) { newValue in
|
||||
.onChange(of: shareCode) { _, newValue in
|
||||
if newValue.count > 6 {
|
||||
shareCode = String(newValue.prefix(6))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
// FIX_SKIPPED: LE-2 — This view calls APILayer directly (loadUsers, loadShareCode,
|
||||
// generateShareCode, removeUser). Fixing requires extracting a dedicated ManageUsersViewModel.
|
||||
// Architectural refactor deferred — requires new ViewModel.
|
||||
struct ManageUsersView: View {
|
||||
let residenceId: Int32
|
||||
let residenceName: String
|
||||
@@ -36,7 +39,6 @@ struct ManageUsersView: View {
|
||||
if isPrimaryOwner {
|
||||
ShareCodeCard(
|
||||
shareCode: shareCode,
|
||||
residenceName: residenceName,
|
||||
isGeneratingCode: isGeneratingCode,
|
||||
isGeneratingPackage: sharingManager.isGeneratingPackage,
|
||||
onGenerateCode: generateShareCode,
|
||||
@@ -81,9 +83,9 @@ struct ManageUsersView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Clear share code on appear so it's always blank
|
||||
shareCode = nil
|
||||
loadUsers()
|
||||
loadShareCode()
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { shareFileURL != nil },
|
||||
|
||||
@@ -19,7 +19,6 @@ struct ResidenceDetailView: View {
|
||||
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditResidence = false
|
||||
@State private var showEditTask = false
|
||||
@State private var showManageUsers = false
|
||||
@State private var selectedTaskForEdit: TaskResponse?
|
||||
@State private var selectedTaskForComplete: TaskResponse?
|
||||
@@ -107,10 +106,13 @@ struct ResidenceDetailView: View {
|
||||
EditResidenceView(residence: residence, isPresented: $showEditResidence)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditTask) {
|
||||
if let task = selectedTaskForEdit {
|
||||
EditTaskView(task: task, isPresented: $showEditTask)
|
||||
}
|
||||
.sheet(item: $selectedTaskForEdit, onDismiss: {
|
||||
loadResidenceTasks(forceRefresh: true)
|
||||
}) { task in
|
||||
EditTaskView(task: task, isPresented: Binding(
|
||||
get: { selectedTaskForEdit != nil },
|
||||
set: { if !$0 { selectedTaskForEdit = nil } }
|
||||
))
|
||||
}
|
||||
.sheet(item: $selectedTaskForComplete, onDismiss: {
|
||||
if let task = pendingCompletedTask {
|
||||
@@ -176,31 +178,26 @@ struct ResidenceDetailView: View {
|
||||
}
|
||||
|
||||
// MARK: onChange & lifecycle
|
||||
.onChange(of: viewModel.reportMessage) { message in
|
||||
.onChange(of: viewModel.reportMessage) { _, message in
|
||||
if message != nil {
|
||||
showReportAlert = true
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.selectedResidence) { residence in
|
||||
.onChange(of: viewModel.selectedResidence) { _, residence in
|
||||
if residence != nil {
|
||||
hasAppeared = true
|
||||
}
|
||||
}
|
||||
.onChange(of: showAddTask) { isShowing in
|
||||
.onChange(of: showAddTask) { _, isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditResidence) { isShowing in
|
||||
.onChange(of: showEditResidence) { _, isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceData()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditTask) { isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadResidenceData()
|
||||
}
|
||||
@@ -252,7 +249,6 @@ private extension ResidenceDetailView {
|
||||
tasksResponse: tasksResponse,
|
||||
taskViewModel: taskViewModel,
|
||||
selectedTaskForEdit: $selectedTaskForEdit,
|
||||
showEditTask: $showEditTask,
|
||||
selectedTaskForComplete: $selectedTaskForComplete,
|
||||
selectedTaskForArchive: $selectedTaskForArchive,
|
||||
showArchiveConfirmation: $showArchiveConfirmation,
|
||||
@@ -335,17 +331,6 @@ private extension ResidenceDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Button Style
|
||||
|
||||
private struct OrganicCardButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbars
|
||||
|
||||
private extension ResidenceDetailView {
|
||||
@@ -466,20 +451,23 @@ private extension ResidenceDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// FIX_SKIPPED: LE-3 — deleteResidence() calls APILayer.shared.deleteResidence() directly
|
||||
// from the view. ResidenceViewModel does not expose a delete method. Fixing requires adding
|
||||
// deleteResidence() to the shared ViewModel layer — architectural refactor deferred.
|
||||
func deleteResidence() {
|
||||
guard TokenStorage.shared.getToken() != nil else { return }
|
||||
|
||||
isDeleting = true
|
||||
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.deleteResidence(
|
||||
id: Int32(Int(residenceId))
|
||||
)
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
self.isDeleting = false
|
||||
|
||||
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
dismiss()
|
||||
} else if let errorResult = ApiResultBridge.error(from: result) {
|
||||
@@ -537,7 +525,6 @@ private struct TasksSectionContainer: View {
|
||||
|
||||
@ObservedObject var taskViewModel: TaskViewModel
|
||||
@Binding var selectedTaskForEdit: TaskResponse?
|
||||
@Binding var showEditTask: Bool
|
||||
@Binding var selectedTaskForComplete: TaskResponse?
|
||||
@Binding var selectedTaskForArchive: TaskResponse?
|
||||
@Binding var showArchiveConfirmation: Bool
|
||||
@@ -556,7 +543,6 @@ private struct TasksSectionContainer: View {
|
||||
tasksResponse: tasksResponse,
|
||||
onEditTask: { task in
|
||||
selectedTaskForEdit = task
|
||||
showEditTask = true
|
||||
},
|
||||
onCancelTask: { task in
|
||||
selectedTaskForCancel = task
|
||||
|
||||
@@ -288,11 +288,6 @@ class ResidenceViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func loadResidenceContractors(residenceId: Int32) {
|
||||
// This can now be handled directly via APILayer if needed
|
||||
// or through DataManagerObservable.shared.contractors
|
||||
}
|
||||
|
||||
func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
@@ -9,10 +9,8 @@ struct ResidencesListView: View {
|
||||
@State private var showingUpgradePrompt = false
|
||||
@State private var showingSettings = false
|
||||
@State private var pushTargetResidenceId: Int32?
|
||||
@State private var showLoginCover = false
|
||||
@StateObject private var authManager = AuthenticationManager.shared
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -32,6 +30,9 @@ struct ResidencesListView: View {
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
viewModel.loadMyResidences()
|
||||
@@ -113,36 +114,19 @@ struct ResidencesListView: View {
|
||||
viewModel.loadMyResidences()
|
||||
// Also load tasks to populate summary stats
|
||||
taskViewModel.loadTasks()
|
||||
} else {
|
||||
showLoginCover = true
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
// Refresh data when app comes back from background
|
||||
if newPhase == .active && authManager.isAuthenticated {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
taskViewModel.loadTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showLoginCover) {
|
||||
LoginView(onLoginSuccess: {
|
||||
authManager.isAuthenticated = true
|
||||
showLoginCover = false
|
||||
viewModel.loadMyResidences()
|
||||
taskViewModel.loadTasks()
|
||||
})
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
.onChange(of: authManager.isAuthenticated) { isAuth in
|
||||
// P-5: Removed redundant .onChange(of: scenePhase) handler.
|
||||
// iOSApp.swift already handles foreground refresh globally, so per-view
|
||||
// scenePhase handlers fire duplicate network requests.
|
||||
.onChange(of: authManager.isAuthenticated) { _, isAuth in
|
||||
if isAuth {
|
||||
// User just logged in or registered - load their residences and tasks
|
||||
showLoginCover = false
|
||||
viewModel.loadMyResidences()
|
||||
taskViewModel.loadTasks()
|
||||
} else {
|
||||
// User logged out - clear data and show login
|
||||
// User logged out - clear data
|
||||
viewModel.myResidences = nil
|
||||
showLoginCover = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in
|
||||
@@ -150,13 +134,8 @@ struct ResidencesListView: View {
|
||||
navigateToResidenceFromPush(residenceId: residenceId)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { pushTargetResidenceId != nil },
|
||||
set: { if !$0 { pushTargetResidenceId = nil } }
|
||||
)) {
|
||||
if let residenceId = pushTargetResidenceId {
|
||||
ResidenceDetailView(residenceId: residenceId)
|
||||
}
|
||||
.navigationDestination(item: $pushTargetResidenceId) { residenceId in
|
||||
ResidenceDetailView(residenceId: residenceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,17 +232,6 @@ private struct ResidencesContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Button Style
|
||||
|
||||
private struct OrganicCardButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Empty Residences View
|
||||
|
||||
private struct OrganicEmptyResidencesView: View {
|
||||
|
||||
Reference in New Issue
Block a user