Fix logout, share code display, loading states, and multi-user support

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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-08 10:51:51 -06:00
parent e271403d9b
commit 364f98a303
8 changed files with 686 additions and 8 deletions

View File

@@ -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<ResidenceWithTasks>
)
// 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<ResidenceUser>
)
@Serializable
data class RemoveUserResponse(
val message: String
)

View File

@@ -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<List<ResidenceUser>>(emptyList()) }
var ownerId by remember { mutableStateOf<Int?>(null) }
var shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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
)
}
}
}
}
}

View File

@@ -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<TaskDetail?>(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<Residence>).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<Residence>).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<Residence>).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 -> {

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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<ResidenceUsersResponse>,
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<ResidenceShareCode> {
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<ResidenceShareCode> {
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<RemoveUserResponse> {
// 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)
}

View File

@@ -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() {

View File

@@ -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,