From 364f98a303e4465d0d91c2f35240b97c7de1d8b3 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 8 Nov 2025 10:51:51 -0600 Subject: [PATCH] Fix logout, share code display, loading states, and multi-user support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Logout Fix: - Use @EnvironmentObject in MainTabView and ProfileTabView - Pass loginViewModel from LoginView to MainTabView - Handle logout by dismissing main tab when isAuthenticated becomes false - Logout button now properly returns user to login screen Share Code UX: - Clear share code on ManageUsers screen open (iOS & Android) - Remove auto-loading of share codes - User must explicitly generate code each time - Improves security with fresh codes Loading State Improvements: - iOS ResidenceDetailView shows loading immediately on navigation - Android ResidenceDetailScreen enhanced with "Loading residence..." text - Better user feedback during API calls Multi-User Support: - Add isPrimaryOwner and userCount to ResidenceWithTasks model - Update iOS toResidences() extension to include new fields - Sync with backend API changes for shared user access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../com/example/mycrib/models/Residence.kt | 50 ++++ .../mycrib/ui/components/ManageUsersDialog.kt | 279 ++++++++++++++++++ .../ui/screens/ResidenceDetailScreen.kt | 44 ++- iosApp/iosApp/Login/LoginView.swift | 6 + iosApp/iosApp/MainTabView.swift | 4 +- iosApp/iosApp/Residence/ManageUsersView.swift | 272 +++++++++++++++++ .../Residence/ResidenceDetailView.swift | 37 ++- iosApp/iosApp/Task/AllTasksView.swift | 2 + 8 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ManageUsersDialog.kt create mode 100644 iosApp/iosApp/Residence/ManageUsersView.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt index 4c81175..42308a4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt @@ -8,6 +8,8 @@ data class Residence( val id: Int, val owner: Int? = null, @SerialName("owner_username") val ownerUsername: String, + @SerialName("is_primary_owner") val isPrimaryOwner: Boolean = false, + @SerialName("user_count") val userCount: Int = 1, val name: String, @SerialName("property_type") val propertyType: String, @SerialName("street_address") val streetAddress: String, @@ -101,6 +103,8 @@ data class ResidenceWithTasks( val id: Int, val owner: Int, @SerialName("owner_username") val ownerUsername: String, + @SerialName("is_primary_owner") val isPrimaryOwner: Boolean = false, + @SerialName("user_count") val userCount: Int = 1, val name: String, @SerialName("property_type") val propertyType: String, @SerialName("street_address") val streetAddress: String, @@ -136,4 +140,50 @@ data class MyResidencesSummary( data class MyResidencesResponse( val summary: MyResidencesSummary, val residences: List +) + +// Share Code Models +@Serializable +data class ResidenceShareCode( + val id: Int, + val code: String, + val residence: Int, + @SerialName("residence_name") val residenceName: String, + @SerialName("created_by") val createdBy: Int, + @SerialName("created_by_username") val createdByUsername: String, + @SerialName("is_active") val isActive: Boolean, + @SerialName("created_at") val createdAt: String, + @SerialName("expires_at") val expiresAt: String? +) + +@Serializable +data class JoinResidenceRequest( + val code: String +) + +@Serializable +data class JoinResidenceResponse( + val message: String, + val residence: Residence +) + +// User Management Models +@Serializable +data class ResidenceUser( + val id: Int, + val username: String, + val email: String, + @SerialName("first_name") val firstName: String?, + @SerialName("last_name") val lastName: String? +) + +@Serializable +data class ResidenceUsersResponse( + @SerialName("owner_id") val ownerId: Int, + val users: List +) + +@Serializable +data class RemoveUserResponse( + val message: String ) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ManageUsersDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ManageUsersDialog.kt new file mode 100644 index 0000000..d89e980 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/ManageUsersDialog.kt @@ -0,0 +1,279 @@ +package com.mycrib.android.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mycrib.shared.models.ResidenceUser +import com.mycrib.shared.models.ResidenceShareCode +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.ResidenceApi +import com.mycrib.storage.TokenStorage +import kotlinx.coroutines.launch + +@Composable +fun ManageUsersDialog( + residenceId: Int, + residenceName: String, + isPrimaryOwner: Boolean, + onDismiss: () -> Unit, + onUserRemoved: () -> Unit = {} +) { + var users by remember { mutableStateOf>(emptyList()) } + var ownerId by remember { mutableStateOf(null) } + var shareCode by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var isGeneratingCode by remember { mutableStateOf(false) } + + val residenceApi = remember { ResidenceApi() } + val scope = rememberCoroutineScope() + + // Load users + LaunchedEffect(residenceId) { + // Clear share code on open so it's always blank + shareCode = null + + val token = TokenStorage.getToken() + if (token != null) { + when (val result = residenceApi.getResidenceUsers(token, residenceId)) { + is ApiResult.Success -> { + users = result.data.users + ownerId = result.data.ownerId + isLoading = false + } + is ApiResult.Error -> { + error = result.message + isLoading = false + } + else -> {} + } + + // Don't auto-load share code - user must generate it explicitly + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Manage Users") + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, "Close") + } + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (error != null) { + Text( + text = error ?: "Unknown error", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp) + ) + } else { + // Share code section (primary owner only) + if (isPrimaryOwner) { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Share Code", + style = MaterialTheme.typography.titleSmall + ) + if (shareCode != null) { + Text( + text = shareCode!!.code, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + } else { + Text( + text = "No active code", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + FilledTonalButton( + onClick = { + scope.launch { + isGeneratingCode = true + val token = TokenStorage.getToken() + if (token != null) { + when (val result = residenceApi.generateShareCode(token, residenceId)) { + is ApiResult.Success -> { + shareCode = result.data + } + is ApiResult.Error -> { + error = result.message + } + else -> {} + } + } + isGeneratingCode = false + } + }, + enabled = !isGeneratingCode + ) { + Icon(Icons.Default.Share, "Generate", modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (shareCode != null) "New Code" else "Generate") + } + } + + if (shareCode != null) { + Text( + text = "Share this code with others to give them access to $residenceName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + } + + // Users list + Text( + text = "Users (${users.size})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth().height(300.dp) + ) { + items(users) { user -> + UserListItem( + user = user, + isOwner = user.id == ownerId, + isPrimaryOwner = isPrimaryOwner, + onRemove = { + scope.launch { + val token = TokenStorage.getToken() + if (token != null) { + when (residenceApi.removeUser(token, residenceId, user.id)) { + is ApiResult.Success -> { + users = users.filter { it.id != user.id } + onUserRemoved() + } + is ApiResult.Error -> { + // Show error + } + else -> {} + } + } + } + } + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + +@Composable +private fun UserListItem( + user: ResidenceUser, + isOwner: Boolean, + isPrimaryOwner: Boolean, + onRemove: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = user.username, + style = MaterialTheme.typography.bodyLarge + ) + if (isOwner) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "Owner", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + if (!user.email.isNullOrEmpty()) { + Text( + text = user.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + val fullName = listOfNotNull(user.firstName, user.lastName) + .filter { it.isNotEmpty() } + .joinToString(" ") + if (fullName.isNotEmpty()) { + Text( + text = fullName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (isPrimaryOwner && !isOwner) { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove user", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index 05b9572..b39e892 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.mycrib.android.ui.components.AddNewTaskDialog import com.mycrib.android.ui.components.CompleteTaskDialog +import com.mycrib.android.ui.components.ManageUsersDialog import com.mycrib.android.ui.components.common.InfoCard import com.mycrib.android.ui.components.residence.PropertyDetailItem import com.mycrib.android.ui.components.residence.DetailRow @@ -49,6 +50,7 @@ fun ResidenceDetailScreen( var showCompleteDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } var showNewTaskDialog by remember { mutableStateOf(false) } + var showManageUsersDialog by remember { mutableStateOf(false) } LaunchedEffect(residenceId) { residenceViewModel.getResidence(residenceId) { result -> @@ -135,6 +137,24 @@ fun ResidenceDetailScreen( }) } + if (showManageUsersDialog && residenceState is ApiResult.Success) { + val residence = (residenceState as ApiResult.Success).data + ManageUsersDialog( + residenceId = residence.id, + residenceName = residence.name, + isPrimaryOwner = residence.isPrimaryOwner, + onDismiss = { + showManageUsersDialog = false + }, + onUserRemoved = { + // Reload residence to update user count + residenceViewModel.getResidence(residenceId) { result -> + residenceState = result + } + } + ) + } + Scaffold( topBar = { TopAppBar( @@ -147,8 +167,18 @@ fun ResidenceDetailScreen( actions = { // Edit button - only show when residence is loaded if (residenceState is ApiResult.Success) { + val residence = (residenceState as ApiResult.Success).data + + // Manage Users button - only show for primary owners + if (residence.isPrimaryOwner) { + IconButton(onClick = { + showManageUsersDialog = true + }) { + Icon(Icons.Default.People, contentDescription = "Manage Users") + } + } + IconButton(onClick = { - val residence = (residenceState as ApiResult.Success).data onNavigateToEditResidence(residence) }) { Icon(Icons.Default.Edit, contentDescription = "Edit Residence") @@ -178,7 +208,17 @@ fun ResidenceDetailScreen( .padding(paddingValues), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text( + text = "Loading residence...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } is ApiResult.Error -> { diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index c61b4c7..08bc76f 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -133,6 +133,11 @@ struct LoginView: View { } else { showVerification = true } + } else { + // User logged out, dismiss main tab + print("isAuthenticated changed to false, dismissing main tab") + showMainTab = false + showVerification = false } } .onChange(of: viewModel.isVerified) { _, isVerified in @@ -144,6 +149,7 @@ struct LoginView: View { } .fullScreenCover(isPresented: $showMainTab) { MainTabView() + .environmentObject(viewModel) } .fullScreenCover(isPresented: $showVerification) { VerifyEmailView( diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 79b824c..af28335 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MainTabView: View { - @StateObject private var loginViewModel = LoginViewModel() + @EnvironmentObject var loginViewModel: LoginViewModel @State private var selectedTab = 0 var body: some View { @@ -34,7 +34,7 @@ struct MainTabView: View { } struct ProfileTabView: View { - @StateObject private var loginViewModel = LoginViewModel() + @EnvironmentObject var loginViewModel: LoginViewModel @State private var showingProfileEdit = false var body: some View { diff --git a/iosApp/iosApp/Residence/ManageUsersView.swift b/iosApp/iosApp/Residence/ManageUsersView.swift new file mode 100644 index 0000000..4effadf --- /dev/null +++ b/iosApp/iosApp/Residence/ManageUsersView.swift @@ -0,0 +1,272 @@ +import SwiftUI +import ComposeApp + +struct ManageUsersView: View { + let residenceId: Int32 + let residenceName: String + let isPrimaryOwner: Bool + @Environment(\.dismiss) private var dismiss + + @State private var users: [ResidenceUser] = [] + @State private var ownerId: Int32? + @State private var shareCode: ResidenceShareCode? + @State private var isLoading = true + @State private var errorMessage: String? + @State private var isGeneratingCode = false + + private let residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient()) + + var body: some View { + NavigationView { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if isLoading { + ProgressView() + } else if let error = errorMessage { + ErrorView(message: error) { + loadUsers() + } + } else { + ScrollView { + VStack(spacing: 16) { + // Share code section (primary owner only) + if isPrimaryOwner { + ShareCodeCard( + shareCode: shareCode, + residenceName: residenceName, + isGeneratingCode: isGeneratingCode, + onGenerateCode: generateShareCode + ) + .padding(.horizontal) + .padding(.top) + } + + // Users list + VStack(alignment: .leading, spacing: 12) { + Text("Users (\(users.count))") + .font(.headline) + .padding(.horizontal) + + ForEach(users, id: \.id) { user in + UserListItem( + user: user, + isOwner: user.id == ownerId, + isPrimaryOwner: isPrimaryOwner, + onRemove: { + removeUser(userId: user.id) + } + ) + } + } + .padding(.bottom) + } + } + } + } + .navigationTitle("Manage Users") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Close") { + dismiss() + } + } + } + } + .onAppear { + // Clear share code on appear so it's always blank + shareCode = nil + loadUsers() + } + } + + private func loadUsers() { + guard let token = TokenStorage.shared.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + residenceApi.getResidenceUsers(token: token, residenceId: residenceId) { result, error in + if let successResult = result as? ApiResultSuccess, + let responseData = successResult.data as? ResidenceUsersResponse { + self.users = Array(responseData.users) + self.ownerId = responseData.ownerId as? Int32 + self.isLoading = false + + // Don't auto-load share code - user must generate it explicitly + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + + private func loadShareCode() { + guard let token = TokenStorage.shared.getToken() else { return } + + residenceApi.getShareCode(token: token, residenceId: residenceId) { result, error in + if let successResult = result as? ApiResultSuccess { + self.shareCode = successResult.data + } + // It's okay if there's no active share code + } + } + + private func generateShareCode() { + guard let token = TokenStorage.shared.getToken() else { return } + + isGeneratingCode = true + + residenceApi.generateShareCode(token: token, residenceId: residenceId) { result, error in + if let successResult = result as? ApiResultSuccess { + self.shareCode = successResult.data + self.isGeneratingCode = false + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isGeneratingCode = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isGeneratingCode = false + } + } + } + + private func removeUser(userId: Int32) { + guard let token = TokenStorage.shared.getToken() else { return } + + residenceApi.removeUser(token: token, residenceId: residenceId, userId: userId) { result, error in + if result is ApiResultSuccess { + // Remove user from local list + self.users.removeAll { $0.id == userId } + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + } else if let error = error { + self.errorMessage = error.localizedDescription + } + } + } +} + +// MARK: - Share Code Card +struct ShareCodeCard: View { + let shareCode: ResidenceShareCode? + let residenceName: String + let isGeneratingCode: Bool + let onGenerateCode: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Share Code") + .font(.subheadline) + .foregroundColor(.secondary) + + if let shareCode = shareCode { + Text(shareCode.code) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.blue) + } else { + Text("No active code") + .font(.body) + .foregroundColor(.secondary) + } + } + + Spacer() + + Button(action: onGenerateCode) { + HStack { + Image(systemName: "square.and.arrow.up") + Text(shareCode != nil ? "New Code" : "Generate") + } + } + .buttonStyle(.borderedProminent) + .disabled(isGeneratingCode) + } + + if shareCode != nil { + Text("Share this code with others to give them access to \(residenceName)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(12) + } +} + +// MARK: - User List Item +struct UserListItem: View { + let user: ResidenceUser + let isOwner: Bool + let isPrimaryOwner: Bool + let onRemove: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(user.username) + .font(.body) + .fontWeight(.medium) + + if isOwner { + Text("Owner") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.blue) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.1)) + .cornerRadius(4) + } + } + + if !user.email.isEmpty { + Text(user.email) + .font(.caption) + .foregroundColor(.secondary) + } + + let fullName = [user.firstName, user.lastName] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + + if !fullName.isEmpty { + Text(fullName) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + if isPrimaryOwner && !isOwner { + Button(action: onRemove) { + Image(systemName: "trash") + .foregroundColor(.red) + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .padding(.horizontal) + } +} + +#Preview { + ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true) +} diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index ba118d9..209ca8b 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -11,16 +11,23 @@ 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: TaskDetail? @State private var selectedTaskForComplete: TaskDetail? + @State private var hasAppeared = false var body: some View { ZStack { Color(.systemGroupedBackground) .ignoresSafeArea() - if viewModel.isLoading { - ProgressView() + if !hasAppeared || viewModel.isLoading { + VStack(spacing: 16) { + ProgressView() + Text("Loading residence...") + .font(.subheadline) + .foregroundColor(.secondary) + } } else if let error = viewModel.errorMessage { ErrorView(message: error) { loadResidenceData() @@ -85,7 +92,6 @@ struct ResidenceDetailView: View { } } } - .navigationTitle("Property Details") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -98,7 +104,16 @@ struct ResidenceDetailView: View { } } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .navigationBarTrailing) { + // Manage Users button - only show for primary owners + if let residence = viewModel.selectedResidence, residence.isPrimaryOwner { + Button(action: { + showManageUsers = true + }) { + Image(systemName: "person.2") + } + } + Button(action: { showAddTask = true }) { @@ -125,6 +140,15 @@ struct ResidenceDetailView: View { loadResidenceTasks() } } + .sheet(isPresented: $showManageUsers) { + if let residence = viewModel.selectedResidence { + ManageUsersView( + residenceId: residence.id, + residenceName: residence.name, + isPrimaryOwner: residence.isPrimaryOwner + ) + } + } .onChange(of: showAddTask) { isShowing in if !isShowing { loadResidenceTasks() @@ -143,6 +167,11 @@ struct ResidenceDetailView: View { .onAppear { loadResidenceData() } + .onChange(of: viewModel.selectedResidence) { _, residence in + if residence != nil { + hasAppeared = true + } + } } private func loadResidenceData() { diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 774f628..bf78387 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -462,6 +462,8 @@ extension Array where Element == ResidenceWithTasks { id: item.id, owner: KotlinInt(value: item.owner), ownerUsername: item.ownerUsername, + isPrimaryOwner: item.isPrimaryOwner, + userCount: item.userCount, name: item.name, propertyType: item.propertyType, streetAddress: item.streetAddress,