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:
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -> {
|
||||
|
||||
Reference in New Issue
Block a user