This commit is contained in:
Trey t
2025-11-05 20:52:51 -06:00
parent bc289d6c88
commit a8083380aa
12 changed files with 720 additions and 78 deletions

View File

@@ -197,6 +197,9 @@ fun App() {
onAddResidence = {
navController.navigate(AddResidenceRoute)
},
onNavigateToProfile = {
navController.navigate(ProfileRoute)
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
@@ -351,6 +354,24 @@ fun App() {
onTaskUpdated = { navController.popBackStack() }
)
}
composable<ProfileRoute> {
com.mycrib.android.ui.screens.ProfileScreen(
onNavigateBack = {
navController.popBackStack()
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<ProfileRoute> { inclusive = true }
}
}
)
}
}
}

View File

@@ -62,3 +62,10 @@ data class VerifyEmailResponse(
val message: String,
val verified: Boolean
)
@Serializable
data class UpdateProfileRequest(
@SerialName("first_name") val firstName: String? = null,
@SerialName("last_name") val lastName: String? = null,
val email: String? = null
)

View File

@@ -70,3 +70,6 @@ data class EditTaskRoute(
@Serializable
object TasksRoute
@Serializable
object ProfileRoute

View File

@@ -97,4 +97,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
return try {
val response = client.put("$baseUrl/auth/update-profile/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Profile update failed")
}
ApiResult.Error(errorBody["error"] ?: "Profile update failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,256 @@
package com.mycrib.android.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.common.ErrorCard
import com.mycrib.android.viewmodel.AuthViewModel
import com.mycrib.shared.network.ApiResult
import com.mycrib.storage.TokenStorage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
onLogout: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var successMessage by remember { mutableStateOf("") }
var isLoadingUser by remember { mutableStateOf(true) }
val updateState by viewModel.updateProfileState.collectAsState()
// Load current user data
LaunchedEffect(Unit) {
val token = TokenStorage.getToken()
if (token != null) {
val authApi = com.mycrib.shared.network.AuthApi()
when (val result = authApi.getCurrentUser(token)) {
is ApiResult.Success -> {
firstName = result.data.firstName ?: ""
lastName = result.data.lastName ?: ""
email = result.data.email
isLoadingUser = false
}
else -> {
errorMessage = "Failed to load user data"
isLoadingUser = false
}
}
} else {
errorMessage = "Not authenticated"
isLoadingUser = false
}
}
LaunchedEffect(updateState) {
when (updateState) {
is ApiResult.Success -> {
successMessage = "Profile updated successfully"
isLoading = false
errorMessage = ""
viewModel.resetUpdateProfileState()
}
is ApiResult.Error -> {
errorMessage = (updateState as ApiResult.Error).message
isLoading = false
successMessage = ""
}
is ApiResult.Loading -> {
isLoading = true
errorMessage = ""
successMessage = ""
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Profile", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onLogout) {
Icon(Icons.Default.Logout, contentDescription = "Logout")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
if (isLoadingUser) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
// Profile Icon
Icon(
Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"Update Your Profile",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = firstName,
onValueChange = { firstName = it },
label = { Text("First Name") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = lastName,
onValueChange = { lastName = it },
label = { Text("Last Name") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
if (errorMessage.isNotEmpty()) {
ErrorCard(message = errorMessage)
}
if (successMessage.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
successMessage,
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (email.isNotEmpty()) {
viewModel.updateProfile(
firstName = firstName.ifBlank { null },
lastName = lastName.ifBlank { null },
email = email
)
} else {
errorMessage = "Email is required"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = email.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Save, contentDescription = null)
Text(
"Save Changes",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}

View File

@@ -25,6 +25,7 @@ fun ResidencesScreen(
onResidenceClick: (Int) -> Unit,
onAddResidence: () -> Unit,
onLogout: () -> Unit,
onNavigateToProfile: () -> Unit = {},
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val myResidencesState by viewModel.myResidencesState.collectAsState()
@@ -43,6 +44,9 @@ fun ResidencesScreen(
)
},
actions = {
IconButton(onClick = onNavigateToProfile) {
Icon(Icons.Default.AccountCircle, contentDescription = "Profile")
}
IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = "Logout")
}

View File

@@ -28,6 +28,9 @@ class AuthViewModel : ViewModel() {
private val _verifyEmailState = MutableStateFlow<ApiResult<VerifyEmailResponse>>(ApiResult.Idle)
val verifyEmailState: StateFlow<ApiResult<VerifyEmailResponse>> = _verifyEmailState
private val _updateProfileState = MutableStateFlow<ApiResult<User>>(ApiResult.Idle)
val updateProfileState: StateFlow<ApiResult<User>> = _updateProfileState
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = ApiResult.Loading
@@ -94,6 +97,34 @@ class AuthViewModel : ViewModel() {
_verifyEmailState.value = ApiResult.Idle
}
fun updateProfile(firstName: String?, lastName: String?, email: String?) {
viewModelScope.launch {
_updateProfileState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
val result = authApi.updateProfile(
token = token,
request = com.mycrib.shared.models.UpdateProfileRequest(
firstName = firstName,
lastName = lastName,
email = email
)
)
_updateProfileState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
} else {
_updateProfileState.value = ApiResult.Error("Not authenticated")
}
}
}
fun resetUpdateProfileState() {
_updateProfileState.value = ApiResult.Idle
}
fun logout() {
viewModelScope.launch {
val token = TokenStorage.getToken()