Files
honeyDueKMP/iosApp/iosApp/Residence/ResidenceDetailView.swift
Trey t 09be5fa444 Add cancel task confirmation dialog
- Add L10n strings for cancel confirmation
- Add confirmation dialog to ResidenceDetailView
- Fix AllTasksView cancel dialog (was using archive strings)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:46:13 -06:00

550 lines
20 KiB
Swift

import SwiftUI
import ComposeApp
struct ResidenceDetailView: View {
let residenceId: Int32
@StateObject private var viewModel = ResidenceViewModel()
@StateObject private var taskViewModel = TaskViewModel()
@ObservedObject private var dataManager = DataManagerObservable.shared
// Use TaskViewModel's state instead of local state
private var tasksResponse: TaskColumnsResponse? { taskViewModel.tasksResponse }
private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks }
private var tasksError: String? { taskViewModel.tasksError }
@State private var contractors: [ContractorSummary] = []
@State private var isLoadingContractors = false
@State private var contractorsError: String?
@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?
@State private var selectedTaskForArchive: TaskResponse?
@State private var showArchiveConfirmation = false
@State private var selectedTaskForCancel: TaskResponse?
@State private var showCancelConfirmation = false
@State private var hasAppeared = false
@State private var showReportAlert = false
@State private var showReportConfirmation = false
@State private var showDeleteConfirmation = false
@State private var isDeleting = false
@State private var showingUpgradePrompt = false
@State private var upgradeTriggerKey = ""
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@Environment(\.dismiss) private var dismiss
// Check if current user is the owner of the residence
private func isCurrentUserOwner(of residence: ResidenceResponse) -> Bool {
guard let currentUser = dataManager.currentUser else {
return false
}
return Int(residence.ownerId) == Int(currentUser.id)
}
var body: some View {
ZStack {
WarmGradientBackground()
mainContent
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
leadingToolbar
trailingToolbar
}
// MARK: Alerts
.alert(L10n.Residences.generateReport, isPresented: $showReportConfirmation) {
Button(L10n.Common.cancel, role: .cancel) {
showReportConfirmation = false
}
Button(L10n.Residences.generate) {
viewModel.generateTasksReport(residenceId: residenceId, email: "")
showReportConfirmation = false
}
} message: {
Text(L10n.Residences.generateReportMessage)
}
.alert(L10n.Residences.deleteTitle, isPresented: $showDeleteConfirmation) {
Button(L10n.Common.cancel, role: .cancel) { }
.accessibilityIdentifier(AccessibilityIdentifiers.Alert.cancelButton)
Button(L10n.Common.delete, role: .destructive) {
deleteResidence()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Alert.deleteButton)
} message: {
if let residence = viewModel.selectedResidence {
Text("\(L10n.Residences.deleteConfirmMessage)")
}
}
.alert(L10n.Residences.maintenanceReport, isPresented: $showReportAlert) {
Button(L10n.Common.ok, role: .cancel) { }
} message: {
Text(viewModel.reportMessage ?? "")
}
// MARK: Sheets
.sheet(isPresented: $showAddTask) {
AddTaskView(residenceId: residenceId, isPresented: $showAddTask)
}
.sheet(isPresented: $showEditResidence) {
if let residence = viewModel.selectedResidence {
EditResidenceView(residence: residence, isPresented: $showEditResidence)
}
}
.sheet(isPresented: $showEditTask) {
if let task = selectedTaskForEdit {
EditTaskView(task: task, isPresented: $showEditTask)
}
}
.sheet(item: $selectedTaskForComplete) { 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)
}
selectedTaskForComplete = nil
}
}
.sheet(isPresented: $showManageUsers) {
if let residence = viewModel.selectedResidence {
ManageUsersView(
residenceId: residence.id,
residenceName: residence.name,
isPrimaryOwner: isCurrentUserOwner(of: residence),
residenceOwnerId: residence.ownerId,
residence: residence
)
}
}
.alert("Archive Task", isPresented: $showArchiveConfirmation) {
Button("Cancel", role: .cancel) {
selectedTaskForArchive = nil
}
Button("Archive", role: .destructive) {
if let task = selectedTaskForArchive {
taskViewModel.archiveTask(id: task.id) { _ in
loadResidenceTasks()
}
selectedTaskForArchive = nil
}
}
} message: {
if let task = selectedTaskForArchive {
Text("Are you sure you want to archive \"\(task.title)\"? You can unarchive it later from archived tasks.")
}
}
.alert(L10n.Tasks.cancelTask, isPresented: $showCancelConfirmation) {
Button(L10n.Common.no, role: .cancel) {
selectedTaskForCancel = nil
}
Button(L10n.Common.yes, role: .destructive) {
if let task = selectedTaskForCancel {
taskViewModel.cancelTask(id: task.id) { _ in
loadResidenceTasks()
}
selectedTaskForCancel = nil
}
}
} message: {
Text(L10n.Tasks.cancelConfirm)
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: upgradeTriggerKey.isEmpty ? "add_11th_task" : upgradeTriggerKey, isPresented: $showingUpgradePrompt)
}
// MARK: onChange & lifecycle
.onChange(of: viewModel.reportMessage) { message in
if message != nil {
showReportAlert = true
}
}
.onChange(of: viewModel.selectedResidence) { residence in
if residence != nil {
hasAppeared = true
}
}
.onChange(of: showAddTask) { isShowing in
if !isShowing {
loadResidenceTasks(forceRefresh: true)
}
}
.onChange(of: showEditResidence) { isShowing in
if !isShowing {
loadResidenceData()
}
}
.onChange(of: showEditTask) { isShowing in
if !isShowing {
loadResidenceTasks(forceRefresh: true)
}
}
.onAppear {
loadResidenceData()
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { loadResidenceData() }
)
}
}
// MARK: - Main Content
private extension ResidenceDetailView {
@ViewBuilder
var mainContent: some View {
if !hasAppeared || viewModel.isLoading {
loadingView
} else if let residence = viewModel.selectedResidence {
contentView(for: residence)
}
}
var loadingView: some View {
StandardLoadingView(message: L10n.Residences.loadingResidence)
}
@ViewBuilder
func contentView(for residence: ResidenceResponse) -> some View {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
PropertyHeaderCard(residence: residence)
.padding(.horizontal, 16)
.padding(.top, 8)
tasksSection
.padding(.horizontal, 16)
contractorsSection
.padding(.horizontal, 16)
}
.padding(.bottom, OrganicSpacing.airy)
}
}
@ViewBuilder
var tasksSection: some View {
if let tasksResponse = tasksResponse {
TasksSectionContainer(
tasksResponse: tasksResponse,
taskViewModel: taskViewModel,
selectedTaskForEdit: $selectedTaskForEdit,
showEditTask: $showEditTask,
selectedTaskForComplete: $selectedTaskForComplete,
selectedTaskForArchive: $selectedTaskForArchive,
showArchiveConfirmation: $showArchiveConfirmation,
reloadTasks: { loadResidenceTasks(forceRefresh: true) }
)
} else if isLoadingTasks {
ProgressView(L10n.Residences.loadingTasks)
} else if let tasksError = tasksError {
Text("\(L10n.Residences.errorLoadingTasks): \(tasksError)")
.foregroundColor(Color.appError)
.padding()
}
}
@ViewBuilder
var contractorsSection: some View {
VStack(alignment: .leading, spacing: 16) {
// Section Header
HStack(alignment: .center, spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 40, height: 40)
Image(systemName: "person.2.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
Text(L10n.Residences.contractors)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
.padding(.top, 8)
if isLoadingContractors {
HStack {
Spacer()
ProgressView()
.tint(Color.appPrimary)
Spacer()
}
.padding(OrganicSpacing.cozy)
} else if let error = contractorsError {
Text("\(L10n.Common.error): \(error)")
.foregroundColor(Color.appError)
.padding()
} else if contractors.isEmpty {
// Empty state with organic styling
OrganicEmptyState(
icon: "person.crop.circle.badge.plus",
title: L10n.Residences.noContractors,
subtitle: L10n.Residences.addContractorsPrompt,
blobVariation: 1
)
} else {
// Contractors list
VStack(spacing: 12) {
ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard(
contractor: contractor,
onToggleFavorite: {
// Could implement toggle favorite here if needed
}
)
}
.buttonStyle(OrganicCardButtonStyle())
}
}
}
}
}
}
// 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 {
@ToolbarContentBuilder
var leadingToolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
if viewModel.selectedResidence != nil {
Button(L10n.Common.edit) {
showEditResidence = true
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.editButton)
}
}
}
@ToolbarContentBuilder
var trailingToolbar: some ToolbarContent {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if viewModel.selectedResidence != nil {
Button {
showReportConfirmation = true
} label: {
if viewModel.isGeneratingReport {
ProgressView()
} else {
Image(systemName: "doc.text")
}
}
.disabled(viewModel.isGeneratingReport)
}
// Manage Users button (owner only) - includes share code generation and easy share
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
if subscriptionCache.canShareResidence() {
showManageUsers = true
} else {
upgradeTriggerKey = "share_residence"
showingUpgradePrompt = true
}
} label: {
Image(systemName: "person.2")
}
}
Button {
// Check LIVE task count before adding
let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTasks, limitKey: "tasks") {
upgradeTriggerKey = "add_11th_task"
showingUpgradePrompt = true
} else {
showAddTask = true
}
} label: {
Image(systemName: "plus")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
showDeleteConfirmation = true
} label: {
Image(systemName: "trash")
.foregroundStyle(Color.appError)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.deleteButton)
}
}
}
}
// MARK: - Data Loading
private extension ResidenceDetailView {
func loadResidenceData() {
viewModel.getResidence(id: residenceId)
loadResidenceTasks()
loadResidenceContractors()
}
func loadResidenceTasks(forceRefresh: Bool = false) {
taskViewModel.loadTasks(residenceId: residenceId, forceRefresh: forceRefresh)
}
func updateTaskInKanban(_ updatedTask: TaskResponse) {
taskViewModel.updateTaskInKanban(updatedTask)
}
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 = result as? ApiResultError {
self.viewModel.errorMessage = ErrorMessageParser.parse(errorResult.message)
} else {
self.viewModel.errorMessage = "Failed to delete residence"
}
}
} catch {
await MainActor.run {
self.isDeleting = false
self.viewModel.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}
}
func loadResidenceContractors() {
guard TokenStorage.shared.getToken() != nil else { return }
isLoadingContractors = true
contractorsError = nil
Task {
do {
let result = try await APILayer.shared.getContractorsByResidence(
residenceId: Int32(Int(residenceId)),
forceRefresh: false
)
await MainActor.run {
if let successResult = result as? ApiResultSuccess<NSArray> {
self.contractors = (successResult.data as? [ContractorSummary]) ?? []
self.isLoadingContractors = false
} else if let errorResult = result as? ApiResultError {
self.contractorsError = errorResult.message
self.isLoadingContractors = false
} else {
self.contractorsError = "Failed to load contractors"
self.isLoadingContractors = false
}
}
} catch {
await MainActor.run {
self.contractorsError = ErrorMessageParser.parse(error.localizedDescription)
self.isLoadingContractors = false
}
}
}
}
}
private struct TasksSectionContainer: View {
let tasksResponse: TaskColumnsResponse
@ObservedObject var taskViewModel: TaskViewModel
@Binding var selectedTaskForEdit: TaskResponse?
@Binding var showEditTask: Bool
@Binding var selectedTaskForComplete: TaskResponse?
@Binding var selectedTaskForArchive: TaskResponse?
@Binding var showArchiveConfirmation: Bool
let reloadTasks: () -> Void
var body: some View {
TasksSection(
tasksResponse: tasksResponse,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: { task in
selectedTaskForCancel = task
showCancelConfirmation = true
},
onUncancelTask: { taskId in
taskViewModel.uncancelTask(id: taskId) { _ in
reloadTasks()
}
},
onMarkInProgress: { taskId in
taskViewModel.markInProgress(id: taskId) { success in
if success {
reloadTasks()
}
}
},
onCompleteTask: { task in
selectedTaskForComplete = task
},
onArchiveTask: { task in
selectedTaskForArchive = task
showArchiveConfirmation = true
},
onUnarchiveTask: { taskId in
taskViewModel.unarchiveTask(id: taskId) { _ in
reloadTasks()
}
}
)
}
}
// MARK: - Preview
struct ResidenceDetailView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ResidenceDetailView(residenceId: 1)
}
}
}