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

View File

@@ -4,7 +4,8 @@ struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
@FocusState private var focusedField: Field?
@State private var showingRegister = false
@State private var showingVerifyEmail = false
@State private var showMainTab = false
@State private var showVerification = false
enum Field {
case username, password
@@ -98,22 +99,40 @@ struct LoginView: View {
}
.navigationTitle("Welcome Back")
.navigationBarTitleDisplayMode(.large)
.fullScreenCover(isPresented: $viewModel.isAuthenticated) {
if viewModel.isVerified {
MainTabView()
} else {
VerifyEmailView(
onVerifySuccess: {
// After verification, show main tab view
viewModel.isVerified = true
},
onLogout: {
// Logout and dismiss verification screen
viewModel.logout()
}
)
.onChange(of: viewModel.isAuthenticated) { _, isAuth in
if isAuth {
print("isAuthenticated changed to true, isVerified = \(viewModel.isVerified)")
if viewModel.isVerified {
showMainTab = true
} else {
showVerification = true
}
}
}
.onChange(of: viewModel.isVerified) { _, isVerified in
print("isVerified changed to \(isVerified)")
if isVerified && viewModel.isAuthenticated {
showVerification = false
showMainTab = true
}
}
.fullScreenCover(isPresented: $showMainTab) {
MainTabView()
}
.fullScreenCover(isPresented: $showVerification) {
VerifyEmailView(
onVerifySuccess: {
// After verification, show main tab view
viewModel.isVerified = true
},
onLogout: {
// Logout and dismiss verification screen
viewModel.logout()
showVerification = false
showMainTab = false
}
)
}
.sheet(isPresented: $showingRegister) {
RegisterView()
}

View File

@@ -82,16 +82,21 @@ class LoginViewModel: ObservableObject {
// Store user data and verification status
self.currentUser = user
self.isVerified = user.verified
// Initialize lookups repository after successful login
LookupsManager.shared.initialize()
// Update authentication state
self.isAuthenticated = true
self.isLoading = false
print("Login successful! Token: token")
print("User: \(user.username), Verified: \(user.verified)")
print("isVerified set to: \(self.isVerified)")
// Initialize lookups repository after successful login
LookupsManager.shared.initialize()
// Update authentication state AFTER setting verified status
// Small delay to ensure state updates are processed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.isAuthenticated = true
print("isAuthenticated set to true, isVerified is: \(self.isVerified)")
}
}
}
@@ -113,9 +118,13 @@ class LoginViewModel: ObservableObject {
// Reset state
isAuthenticated = false
isVerified = false
currentUser = nil
username = ""
password = ""
errorMessage = nil
print("Logged out - all state reset")
}
func clearError() {

View File

@@ -20,77 +20,83 @@ struct MainTabView: View {
}
.tag(1)
ProfileView()
.tabItem {
Label("Profile", systemImage: "person.fill")
}
.tag(2)
NavigationView {
ProfileTabView()
}
.tabItem {
Label("Profile", systemImage: "person.fill")
}
.tag(2)
}
}
}
struct ProfileView: View {
struct ProfileTabView: View {
@StateObject private var loginViewModel = LoginViewModel()
@Environment(\.dismiss) var dismiss
@State private var showingProfileEdit = false
var body: some View {
NavigationView {
List {
Section {
HStack {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 60, height: 60)
.foregroundColor(.blue)
List {
Section {
HStack {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 60, height: 60)
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 4) {
Text("User Profile")
.font(.headline)
Text("Manage your account")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
Section("Settings") {
NavigationLink(destination: Text("Account Settings")) {
Label("Account Settings", systemImage: "gear")
}
NavigationLink(destination: Text("Notifications")) {
Label("Notifications", systemImage: "bell")
}
NavigationLink(destination: Text("Privacy")) {
Label("Privacy", systemImage: "lock.shield")
}
}
Section {
Button(action: {
loginViewModel.logout()
}) {
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
.foregroundColor(.red)
}
}
Section {
VStack(alignment: .leading, spacing: 4) {
Text("MyCrib")
.font(.caption)
.fontWeight(.semibold)
Text("User Profile")
.font(.headline)
Text("Version 1.0.0")
.font(.caption2)
Text("Manage your account")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
.navigationTitle("Profile")
Section("Account") {
Button(action: {
showingProfileEdit = true
}) {
Label("Edit Profile", systemImage: "person.crop.circle")
.foregroundColor(.primary)
}
NavigationLink(destination: Text("Notifications")) {
Label("Notifications", systemImage: "bell")
}
NavigationLink(destination: Text("Privacy")) {
Label("Privacy", systemImage: "lock.shield")
}
}
Section {
Button(action: {
loginViewModel.logout()
}) {
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
.foregroundColor(.red)
}
}
Section {
VStack(alignment: .leading, spacing: 4) {
Text("MyCrib")
.font(.caption)
.fontWeight(.semibold)
Text("Version 1.0.0")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
.navigationTitle("Profile")
.sheet(isPresented: $showingProfileEdit) {
ProfileView()
}
}
}

View File

@@ -0,0 +1,142 @@
import SwiftUI
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
@Environment(\.dismiss) var dismiss
@FocusState private var focusedField: Field?
enum Field {
case firstName, lastName, email
}
var body: some View {
NavigationView {
if viewModel.isLoadingUser {
VStack {
ProgressView()
Text("Loading profile...")
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.top, 8)
}
} else {
Form {
Section {
VStack(spacing: 16) {
Image(systemName: "person.circle.fill")
.font(.system(size: 60))
.foregroundStyle(.blue.gradient)
Text("Profile Settings")
.font(.title2)
.fontWeight(.bold)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
Section {
TextField("First Name", text: $viewModel.firstName)
.textInputAutocapitalization(.words)
.autocorrectionDisabled()
.focused($focusedField, equals: .firstName)
.submitLabel(.next)
.onSubmit {
focusedField = .lastName
}
TextField("Last Name", text: $viewModel.lastName)
.textInputAutocapitalization(.words)
.autocorrectionDisabled()
.focused($focusedField, equals: .lastName)
.submitLabel(.next)
.onSubmit {
focusedField = .email
}
} header: {
Text("Personal Information")
}
Section {
TextField("Email", text: $viewModel.email)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.focused($focusedField, equals: .email)
.submitLabel(.done)
.onSubmit {
viewModel.updateProfile()
}
} header: {
Text("Contact")
} footer: {
Text("Email is required and must be unique")
}
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.subheadline)
}
}
}
if let successMessage = viewModel.successMessage {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.subheadline)
}
}
}
Section {
Button(action: viewModel.updateProfile) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Text("Save Changes")
.fontWeight(.semibold)
}
Spacer()
}
}
.disabled(viewModel.isLoading || viewModel.email.isEmpty)
}
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
.onChange(of: viewModel.firstName) { _, _ in
viewModel.clearMessages()
}
.onChange(of: viewModel.lastName) { _, _ in
viewModel.clearMessages()
}
.onChange(of: viewModel.email) { _, _ in
viewModel.clearMessages()
}
}
}
}
}
#Preview {
ProfileView()
}

View File

@@ -0,0 +1,121 @@
import Foundation
import ComposeApp
import Combine
@MainActor
class ProfileViewModel: ObservableObject {
// MARK: - Published Properties
@Published var firstName: String = ""
@Published var lastName: String = ""
@Published var email: String = ""
@Published var isLoading: Bool = false
@Published var isLoadingUser: Bool = true
@Published var errorMessage: String?
@Published var successMessage: String?
// MARK: - Private Properties
private let authApi: AuthApi
private let tokenStorage: TokenStorage
// MARK: - Initialization
init() {
self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient())
self.tokenStorage = TokenStorage()
// Initialize TokenStorage with platform-specific manager
self.tokenStorage.initialize(manager: TokenManager.init())
// Load current user data
loadCurrentUser()
}
// MARK: - Public Methods
func loadCurrentUser() {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
isLoadingUser = false
return
}
isLoadingUser = true
errorMessage = nil
authApi.getCurrentUser(token: token) { result, error in
if let successResult = result as? ApiResultSuccess<User> {
self.handleLoadSuccess(user: successResult.data!)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoadingUser = false
} else {
self.errorMessage = "Failed to load user data"
self.isLoadingUser = false
}
}
}
func updateProfile() {
guard !email.isEmpty else {
errorMessage = "Email is required"
return
}
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
return
}
isLoading = true
errorMessage = nil
successMessage = nil
let request = UpdateProfileRequest(
firstName: firstName.isEmpty ? nil : firstName,
lastName: lastName.isEmpty ? nil : lastName,
email: email
)
authApi.updateProfile(token: token, request: request) { result, error in
if let successResult = result as? ApiResultSuccess<User> {
self.handleUpdateSuccess(user: successResult.data!)
} else if let error = error {
self.handleError(message: error.localizedDescription)
} else {
self.handleError(message: "Failed to update profile")
}
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
// MARK: - Private Methods
@MainActor
private func handleLoadSuccess(user: User) {
firstName = user.firstName ?? ""
lastName = user.lastName ?? ""
email = user.email
isLoadingUser = false
errorMessage = nil
}
@MainActor
private func handleUpdateSuccess(user: User) {
firstName = user.firstName ?? ""
lastName = user.lastName ?? ""
email = user.email
isLoading = false
errorMessage = nil
successMessage = "Profile updated successfully"
print("Profile updated: \(user.firstName ?? "") \(user.lastName ?? "")")
}
@MainActor
private func handleError(message: String) {
isLoading = false
errorMessage = message
successMessage = nil
}
}