- 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>
550 lines
20 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|