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