diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2ff5fd1..b2a1cf40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,11 +113,15 @@ dependencies { implementation("com.google.android.play:integrity:1.4.0") // Firebase Functions — callable for server-side integrity token verification + implementation("com.google.firebase:firebase-storage-ktx") implementation("com.google.firebase:firebase-functions-ktx") // RevenueCat native Android SDK (group: com.revenuecat.purchases, artifact: purchases) implementation("com.revenuecat.purchases:purchases:8.20.0") + // Image loading + implementation("io.coil-kt:coil-compose:2.7.0") + // Google Sign-In via Credential Manager implementation("androidx.credentials:credentials:1.3.0") implementation("androidx.credentials:credentials-play-services-auth:1.3.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c6b4c36e..8c1f365a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + + + + + diff --git a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt index 48e492d2..4283dcda 100644 --- a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt @@ -1,6 +1,7 @@ package app.closer.data.remote import app.closer.domain.model.AuthState +import app.closer.domain.model.GoogleSignInResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.GoogleAuthProvider import kotlinx.coroutines.channels.awaitClose @@ -20,6 +21,9 @@ class FirebaseAuthDataSource @Inject constructor() { val currentUserId: String? get() = auth.currentUser?.uid val currentUserEmail: String? get() = auth.currentUser?.email val isSignedIn: Boolean get() = auth.currentUser != null + val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false + val isGoogleAccount: Boolean + get() = auth.currentUser?.providerData?.any { it.providerId == GoogleAuthProvider.PROVIDER_ID } == true val authState: Flow = callbackFlow { trySend(snapshot()) @@ -65,11 +69,22 @@ class FirebaseAuthDataSource @Inject constructor() { .addOnFailureListener { cont.resumeWithException(it) } } - suspend fun signInWithGoogle(idToken: String): String = + suspend fun signInWithGoogle(idToken: String): GoogleSignInResult = suspendCancellableCoroutine { cont -> val credential = GoogleAuthProvider.getCredential(idToken, null) auth.signInWithCredential(credential) - .addOnSuccessListener { cont.resume(it.user?.uid ?: "") } + .addOnSuccessListener { result -> + val user = auth.currentUser ?: result.user + cont.resume( + GoogleSignInResult( + uid = user?.uid ?: "", + displayName = user?.displayName ?: "", + photoUrl = user?.photoUrl?.toString() ?: "", + email = user?.email ?: "", + isAnonymous = user?.isAnonymous ?: false + ) + ) + } .addOnFailureListener { cont.resumeWithException(it) } } diff --git a/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt new file mode 100644 index 00000000..3b4d4be2 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt @@ -0,0 +1,24 @@ +package app.closer.data.remote + +import android.net.Uri +import com.google.firebase.storage.FirebaseStorage +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Singleton +class FirebaseStorageDataSource @Inject constructor() { + + private val storage = FirebaseStorage.getInstance() + + suspend fun uploadProfilePhoto(uid: String, uri: Uri): String = + suspendCancellableCoroutine { cont -> + val ref = storage.reference.child("users/$uid/profile.jpg") + ref.putFile(uri) + .continueWithTask { ref.downloadUrl } + .addOnSuccessListener { cont.resume(it.toString()) } + .addOnFailureListener { cont.resumeWithException(it) } + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt index 80152a65..c82abeec 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt @@ -25,6 +25,7 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest email = snap.getString("email") ?: "", displayName = snap.getString("displayName") ?: "", photoUrl = snap.getString("photoUrl") ?: "", + sex = snap.getString("sex") ?: "", partnerId = snap.getString("partnerId"), coupleId = snap.getString("coupleId"), plan = snap.getString("plan") ?: "free", @@ -43,6 +44,9 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest "email" to user.email, "displayName" to user.displayName, "photoUrl" to user.photoUrl, + "sex" to user.sex, + "partnerId" to user.partnerId, + "coupleId" to user.coupleId, "plan" to user.plan, "createdAt" to user.createdAt, "lastActiveAt" to user.lastActiveAt @@ -62,6 +66,26 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest .addOnFailureListener { cont.resumeWithException(it) } } + suspend fun updatePhotoUrl(uid: String, photoUrl: String): Unit = + suspendCancellableCoroutine { cont -> + userRef(uid).set( + mapOf("photoUrl" to photoUrl, "lastActiveAt" to System.currentTimeMillis()), + SetOptions.merge() + ) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + + suspend fun updateSex(uid: String, sex: String): Unit = + suspendCancellableCoroutine { cont -> + userRef(uid).set( + mapOf("sex" to sex, "lastActiveAt" to System.currentTimeMillis()), + SetOptions.merge() + ) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + suspend fun hasProfile(uid: String): Boolean = suspendCancellableCoroutine { cont -> userRef(uid).get() diff --git a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt index e52a21cf..f1e993fd 100644 --- a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt @@ -2,6 +2,7 @@ package app.closer.data.repository import app.closer.data.remote.FirebaseAuthDataSource import app.closer.domain.model.AuthState +import app.closer.domain.model.GoogleSignInResult import app.closer.domain.repository.AuthRepository import app.closer.domain.security.AuthRateLimiter import kotlinx.coroutines.delay @@ -18,8 +19,10 @@ class FirebaseAuthRepositoryImpl @Inject constructor( override val currentUserId: String? get() = dataSource.currentUserId override val currentUserEmail: String? get() = dataSource.currentUserEmail override val isSignedIn: Boolean get() = dataSource.isSignedIn + override val isAnonymous: Boolean get() = dataSource.isAnonymous + override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount - override suspend fun signInWithGoogle(idToken: String): Result = + override suspend fun signInWithGoogle(idToken: String): Result = withRateLimit(AuthRateLimiter.Flow.LOGIN) { runCatching { dataSource.signInWithGoogle(idToken) } } diff --git a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt index a7954301..a4e75a3d 100644 --- a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt @@ -19,6 +19,12 @@ class UserRepositoryImpl @Inject constructor( override suspend fun updateDisplayName(uid: String, displayName: String) = dataSource.updateDisplayName(uid, displayName) + override suspend fun updatePhotoUrl(uid: String, photoUrl: String) = + dataSource.updatePhotoUrl(uid, photoUrl) + + override suspend fun updateSex(uid: String, sex: String) = + dataSource.updateSex(uid, sex) + override suspend fun hasProfile(uid: String): Boolean = dataSource.hasProfile(uid) override suspend fun storeFcmToken(uid: String, token: String) = diff --git a/app/src/main/java/app/closer/domain/model/GoogleSignInResult.kt b/app/src/main/java/app/closer/domain/model/GoogleSignInResult.kt new file mode 100644 index 00000000..4402955b --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/GoogleSignInResult.kt @@ -0,0 +1,9 @@ +package app.closer.domain.model + +data class GoogleSignInResult( + val uid: String, + val displayName: String, + val photoUrl: String, + val email: String = "", + val isAnonymous: Boolean = false +) diff --git a/app/src/main/java/app/closer/domain/model/User.kt b/app/src/main/java/app/closer/domain/model/User.kt index caa676f5..36e4463f 100644 --- a/app/src/main/java/app/closer/domain/model/User.kt +++ b/app/src/main/java/app/closer/domain/model/User.kt @@ -5,6 +5,7 @@ data class User( val email: String = "", val displayName: String = "", val photoUrl: String = "", + val sex: String = "", val partnerId: String? = null, val coupleId: String? = null, val plan: String = "free", diff --git a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt index 30e471d2..d79073b1 100644 --- a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt @@ -1,6 +1,7 @@ package app.closer.domain.repository import app.closer.domain.model.AuthState +import app.closer.domain.model.GoogleSignInResult import kotlinx.coroutines.flow.Flow interface AuthRepository { @@ -8,7 +9,9 @@ interface AuthRepository { val currentUserId: String? val currentUserEmail: String? val isSignedIn: Boolean - suspend fun signInWithGoogle(idToken: String): Result + val isAnonymous: Boolean + val isGoogleAccount: Boolean + suspend fun signInWithGoogle(idToken: String): Result suspend fun signInAnonymously(): Result suspend fun signInWithEmail(email: String, password: String): Result suspend fun signUpWithEmail(email: String, password: String): Result diff --git a/app/src/main/java/app/closer/domain/repository/UserRepository.kt b/app/src/main/java/app/closer/domain/repository/UserRepository.kt index db8d5e83..cc0db6d4 100644 --- a/app/src/main/java/app/closer/domain/repository/UserRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/UserRepository.kt @@ -7,6 +7,8 @@ interface UserRepository { suspend fun getUser(uid: String): User? suspend fun createUser(user: User) suspend fun updateDisplayName(uid: String, displayName: String) + suspend fun updatePhotoUrl(uid: String, photoUrl: String) + suspend fun updateSex(uid: String, sex: String) suspend fun hasProfile(uid: String): Boolean suspend fun storeFcmToken(uid: String, token: String) suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata) diff --git a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt index d00d639a..82158794 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt @@ -71,7 +71,7 @@ fun LoginScreen( val scope = rememberCoroutineScope() LaunchedEffect(state.success) { - if (state.success) onNavigate(AppRoute.HOME) + if (state.success) onNavigate(AppRoute.ONBOARDING) } LaunchedEffect(state.error) { state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() } diff --git a/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt b/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt index c317d0fb..f7b55200 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt @@ -2,7 +2,10 @@ package app.closer.ui.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.domain.model.GoogleSignInResult +import app.closer.domain.model.User import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,7 +25,8 @@ data class LoginUiState( @HiltViewModel class LoginViewModel @Inject constructor( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val userRepository: UserRepository ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) @@ -43,7 +47,15 @@ class LoginViewModel @Inject constructor( } fun signInWithGoogle(idToken: String) { - attemptSignIn { authRepository.signInWithGoogle(idToken) } + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + authRepository.signInWithGoogle(idToken) + .onSuccess { result -> + mergeGoogleProfile(result) + _uiState.update { it.copy(isLoading = false, success = true) } + } + .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } } + } } fun signInAnonymously() { @@ -61,6 +73,31 @@ class LoginViewModel @Inject constructor( } } + private suspend fun mergeGoogleProfile(result: GoogleSignInResult) { + val uid = result.uid + if (uid.isBlank()) return + val existing = runCatching { userRepository.getUser(uid) }.getOrNull() + if (existing == null) { + userRepository.createUser( + User( + id = uid, + email = result.email, + displayName = result.displayName, + photoUrl = result.photoUrl, + createdAt = System.currentTimeMillis(), + lastActiveAt = System.currentTimeMillis() + ) + ) + } else { + if (existing.displayName.isBlank() && result.displayName.isNotBlank()) { + userRepository.updateDisplayName(uid, result.displayName) + } + if (existing.photoUrl.isBlank() && result.photoUrl.isNotBlank()) { + userRepository.updatePhotoUrl(uid, result.photoUrl) + } + } + } + private fun friendlyError(e: Throwable): String = when { e.message?.contains("no user record") == true -> "No account found with that email." e.message?.contains("password is invalid") == true -> "Incorrect password." diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt index d6248e4c..ddb873ef 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileScreen.kt @@ -1,8 +1,18 @@ package app.closer.ui.onboarding +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,33 +20,55 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AddAPhoto +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import app.closer.core.navigation.AppRoute import app.closer.ui.auth.AuthBackgroundBrush @@ -44,8 +76,12 @@ import app.closer.ui.auth.AuthInk import app.closer.ui.auth.AuthMuted import app.closer.ui.auth.AuthOnPrimary import app.closer.ui.auth.AuthPrimary +import app.closer.ui.auth.AuthPrimaryDeep import app.closer.ui.auth.authTextFieldColors +import coil.compose.AsyncImage +import java.io.File +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateProfileScreen( onNavigate: (String) -> Unit = {}, @@ -54,6 +90,7 @@ fun CreateProfileScreen( val state by viewModel.uiState.collectAsState() val snackbar = remember { SnackbarHostState() } val focusManager = LocalFocusManager.current + val context = LocalContext.current LaunchedEffect(state.success) { if (state.success) onNavigate(AppRoute.HOME) @@ -62,10 +99,54 @@ fun CreateProfileScreen( state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() } } + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri: Uri? -> + uri?.let { viewModel.setPhotoUri(it.toString()) } + } + + val cameraFile = remember(context) { File(context.filesDir, "profile_temp.jpg") } + val cameraUri = remember(cameraFile) { + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + cameraFile + ) + } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + if (success) viewModel.setPhotoUri(cameraUri.toString()) + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) cameraLauncher.launch(cameraUri) + } + Scaffold( snackbarHost = { SnackbarHost(snackbar) }, containerColor = Color.Transparent, - modifier = Modifier.background(AuthBackgroundBrush) + modifier = Modifier.background(AuthBackgroundBrush), + topBar = { + if (state.currentStep != ProfileStep.NAME) { + TopAppBar( + title = { Text("Create profile", color = AuthInk) }, + navigationIcon = { + IconButton(onClick = viewModel::goBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = AuthInk + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + ) + } + } ) { padding -> Column( modifier = Modifier @@ -81,54 +162,284 @@ fun CreateProfileScreen( Spacer(Modifier.height(48.dp)) Text( - text = "What should your\npartner call you?", - style = MaterialTheme.typography.headlineMedium, - color = AuthInk, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "This is how you'll appear to them in the app.", - style = MaterialTheme.typography.bodyMedium, + text = "Step ${state.currentStep.ordinal + 1} of 3", + style = MaterialTheme.typography.labelMedium, color = AuthMuted, textAlign = TextAlign.Center ) + Spacer(Modifier.height(24.dp)) - Spacer(Modifier.height(40.dp)) - - OutlinedTextField( - value = state.displayName, - onValueChange = viewModel::updateDisplayName, - label = { Text("Your name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - colors = authTextFieldColors(), - isError = state.nameError != null, - supportingText = state.nameError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Words, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.saveProfile() }) - ) - - Spacer(Modifier.height(28.dp)) - - Button( - onClick = { focusManager.clearFocus(); viewModel.saveProfile() }, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth().height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = AuthPrimary, - contentColor = AuthOnPrimary + when (state.currentStep) { + ProfileStep.NAME -> NameStep( + state = state, + onNameChange = viewModel::updateDisplayName, + onContinue = { + focusManager.clearFocus() + viewModel.goToNextStep() + } + ) + ProfileStep.SEX -> SexStep( + state = state, + onSexSelected = viewModel::selectSex, + onContinue = viewModel::goToNextStep + ) + ProfileStep.PHOTO -> PhotoStep( + state = state, + onGallery = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + onCamera = { + when { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> cameraLauncher.launch(cameraUri) + else -> cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + onSkip = viewModel::skipPhoto, + onContinue = viewModel::goToNextStep ) - ) { - if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp) - else Text("Continue", style = MaterialTheme.typography.labelLarge) } Spacer(Modifier.height(32.dp)) } } } + +@Composable +private fun NameStep( + state: CreateProfileUiState, + onNameChange: (String) -> Unit, + onContinue: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "What should your\npartner call you?", + style = MaterialTheme.typography.headlineMedium, + color = AuthInk, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "This is how you'll appear to them in the app.", + style = MaterialTheme.typography.bodyMedium, + color = AuthMuted, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(40.dp)) + + OutlinedTextField( + value = state.displayName, + onValueChange = onNameChange, + label = { Text("Your name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = authTextFieldColors(), + isError = state.nameError != null, + supportingText = state.nameError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { onContinue() }) + ) + + Spacer(Modifier.height(28.dp)) + + Button( + onClick = onContinue, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AuthPrimary, + contentColor = AuthOnPrimary + ) + ) { + if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp) + else Text("Continue", style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +private fun SexStep( + state: CreateProfileUiState, + onSexSelected: (String) -> Unit, + onContinue: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "What's your sex?", + style = MaterialTheme.typography.headlineMedium, + color = AuthInk, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Some questions in Desire Sync and other features are tailored differently based on your sex. This helps us show you relevant questions.", + style = MaterialTheme.typography.bodyMedium, + color = AuthMuted, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(32.dp)) + + SexOption( + label = "Female", + selected = state.sex == "female", + onClick = { onSexSelected("female") } + ) + Spacer(Modifier.height(12.dp)) + SexOption( + label = "Male", + selected = state.sex == "male", + onClick = { onSexSelected("male") } + ) + + state.sexError?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Spacer(Modifier.height(28.dp)) + + Button( + onClick = onContinue, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AuthPrimary, + contentColor = AuthOnPrimary + ) + ) { + if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp) + else Text("Continue", style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +private fun SexOption(label: String, selected: Boolean, onClick: () -> Unit) { + val background = if (selected) AuthPrimary else Color.White.copy(alpha = 0.78f) + val contentColor = if (selected) AuthOnPrimary else AuthInk + val borderColor = if (selected) AuthPrimary else AuthMuted.copy(alpha = 0.24f) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clip(RoundedCornerShape(16.dp)) + .background(background) + .border(1.dp, borderColor, RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = contentColor + ) + } +} + +@Composable +private fun PhotoStep( + state: CreateProfileUiState, + onGallery: () -> Unit, + onCamera: () -> Unit, + onSkip: () -> Unit, + onContinue: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Add a photo", + style = MaterialTheme.typography.headlineMedium, + color = AuthInk, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "This is optional. Your partner will see it once you connect.", + style = MaterialTheme.typography.bodyMedium, + color = AuthMuted, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(32.dp)) + + val imageUri = state.photoUri ?: state.photoUrl.takeIf { it.isNotBlank() } + Box( + modifier = Modifier + .size(140.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.78f)) + .border(2.dp, AuthPrimary.copy(alpha = 0.3f), CircleShape), + contentAlignment = Alignment.Center + ) { + if (imageUri != null) { + AsyncImage( + model = imageUri, + contentDescription = "Profile photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + placeholder = rememberVectorPainter(Icons.Filled.Person), + error = rememberVectorPainter(Icons.Filled.Person) + ) + } else { + Icon( + Icons.Filled.Person, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = AuthMuted + ) + } + } + + Spacer(Modifier.height(24.dp)) + + OutlinedButton( + onClick = onGallery, + modifier = Modifier.fillMaxWidth().height(52.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White.copy(alpha = 0.78f), + contentColor = AuthInk + ) + ) { + Icon(Icons.Filled.PhotoLibrary, contentDescription = null, modifier = Modifier.padding(end = 8.dp)) + Text("Choose from gallery", style = MaterialTheme.typography.labelLarge) + } + + Spacer(Modifier.height(12.dp)) + + OutlinedButton( + onClick = onCamera, + modifier = Modifier.fillMaxWidth().height(52.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White.copy(alpha = 0.78f), + contentColor = AuthInk + ) + ) { + Icon(Icons.Filled.AddAPhoto, contentDescription = null, modifier = Modifier.padding(end = 8.dp)) + Text("Take a photo", style = MaterialTheme.typography.labelLarge) + } + + Spacer(Modifier.height(12.dp)) + + TextButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { + Text("Skip for now", color = AuthPrimaryDeep) + } + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = onContinue, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AuthPrimary, + contentColor = AuthOnPrimary + ) + ) { + if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp) + else Text("Continue", style = MaterialTheme.typography.labelLarge) + } + } +} diff --git a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt index 6e98dfa7..936f9ee1 100644 --- a/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/onboarding/CreateProfileViewModel.kt @@ -1,7 +1,9 @@ package app.closer.ui.onboarding +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.data.remote.FirebaseStorageDataSource import app.closer.domain.model.User import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.UserRepository @@ -13,50 +15,133 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +enum class ProfileStep { + NAME, + SEX, + PHOTO +} + data class CreateProfileUiState( + val currentStep: ProfileStep = ProfileStep.NAME, val displayName: String = "", - val nameError: String? = null, + val sex: String = "", + val photoUrl: String = "", + val photoUri: String? = null, val isLoading: Boolean = false, val error: String? = null, - val success: Boolean = false + val success: Boolean = false, + val nameError: String? = null, + val sexError: String? = null ) @HiltViewModel class CreateProfileViewModel @Inject constructor( private val authRepository: AuthRepository, - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val storageDataSource: FirebaseStorageDataSource ) : ViewModel() { private val _uiState = MutableStateFlow(CreateProfileUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun updateDisplayName(name: String) = _uiState.update { it.copy(displayName = name, nameError = null, error = null) } - fun dismissError() = _uiState.update { it.copy(error = null) } + init { + loadExistingProfile() + } - fun saveProfile() { - val name = _uiState.value.displayName.trim() - if (name.isBlank()) { - _uiState.update { it.copy(nameError = "Please enter your name.") } - return + private fun loadExistingProfile() { + val uid = authRepository.currentUserId ?: return + viewModelScope.launch { + val user = runCatching { userRepository.getUser(uid) }.getOrNull() ?: return@launch + _uiState.update { + it.copy( + displayName = user.displayName, + sex = user.sex, + photoUrl = user.photoUrl + ) + } } - if (name.length < 2) { - _uiState.update { it.copy(nameError = "Name must be at least 2 characters.") } + } + + fun updateDisplayName(name: String) = _uiState.update { it.copy(displayName = name, nameError = null, error = null) } + + fun selectSex(sex: String) = _uiState.update { it.copy(sex = sex, sexError = null, error = null) } + + fun setPhotoUri(uri: String?) = _uiState.update { it.copy(photoUri = uri, error = null) } + + fun goBack() { + val previous = when (_uiState.value.currentStep) { + ProfileStep.NAME -> ProfileStep.NAME + ProfileStep.SEX -> ProfileStep.NAME + ProfileStep.PHOTO -> ProfileStep.SEX + } + _uiState.update { it.copy(currentStep = previous, error = null) } + } + + fun goToNextStep() { + val state = _uiState.value + when (state.currentStep) { + ProfileStep.NAME -> { + val name = state.displayName.trim() + when { + name.isBlank() -> _uiState.update { it.copy(nameError = "Please enter your name.") } + name.length < 2 -> _uiState.update { it.copy(nameError = "Name must be at least 2 characters.") } + else -> _uiState.update { it.copy(currentStep = ProfileStep.SEX, nameError = null) } + } + } + ProfileStep.SEX -> { + if (state.sex.isBlank()) { + _uiState.update { it.copy(sexError = "Please select an option so we can tailor your experience.") } + } else { + _uiState.update { it.copy(currentStep = ProfileStep.PHOTO, sexError = null) } + } + } + ProfileStep.PHOTO -> saveProfile() + } + } + + fun skipPhoto() = saveProfile(skipPhoto = true) + + fun saveProfile(skipPhoto: Boolean = false) { + val state = _uiState.value + val name = state.displayName.trim() + if (name.isBlank() || name.length < 2 || state.sex.isBlank()) { + _uiState.update { + it.copy( + nameError = if (name.isBlank()) "Please enter your name." else if (name.length < 2) "Name must be at least 2 characters." else null, + sexError = if (state.sex.isBlank()) "Please select your sex." else null + ) + } return } val uid = authRepository.currentUserId ?: run { _uiState.update { it.copy(error = "Not signed in. Please sign in and try again.") } return } - _uiState.update { it.copy(isLoading = true, nameError = null) } + _uiState.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { runCatching { + val finalPhotoUrl = if (!skipPhoto && !state.photoUri.isNullOrBlank()) { + storageDataSource.uploadProfilePhoto(uid, Uri.parse(state.photoUri)) + } else { + state.photoUrl + } val existing = userRepository.getUser(uid) + val user = existing ?: User( + id = uid, + displayName = name, + sex = state.sex, + photoUrl = finalPhotoUrl, + createdAt = System.currentTimeMillis(), + lastActiveAt = System.currentTimeMillis() + ) if (existing == null) { - userRepository.createUser( - User(id = uid, displayName = name, createdAt = System.currentTimeMillis(), lastActiveAt = System.currentTimeMillis()) - ) + userRepository.createUser(user.copy(displayName = name, sex = state.sex, photoUrl = finalPhotoUrl)) } else { userRepository.updateDisplayName(uid, name) + userRepository.updateSex(uid, state.sex) + if (finalPhotoUrl.isNotBlank()) { + userRepository.updatePhotoUrl(uid, finalPhotoUrl) + } } }.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } @@ -65,4 +150,6 @@ class CreateProfileViewModel @Inject constructor( } } } + + fun dismissError() = _uiState.update { it.copy(error = null) } } diff --git a/app/src/main/java/app/closer/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/app/closer/ui/onboarding/OnboardingViewModel.kt index 8b279b04..18ecdecd 100644 --- a/app/src/main/java/app/closer/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/app/closer/ui/onboarding/OnboardingViewModel.kt @@ -39,7 +39,7 @@ class OnboardingViewModel @Inject constructor( .onFailure { Log.w(TAG, "Could not load user profile during onboarding", it) } .getOrNull() val destination = when { - user == null || user.displayName.isBlank() -> "create_profile" + user == null || user.displayName.isBlank() || user.sex.isBlank() -> "create_profile" else -> "home" } _uiState.update { it.copy(isCheckingAuth = false, navigateTo = destination) } diff --git a/app/src/main/java/app/closer/ui/settings/AccountScreen.kt b/app/src/main/java/app/closer/ui/settings/AccountScreen.kt index 18330400..e46e9881 100644 --- a/app/src/main/java/app/closer/ui/settings/AccountScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/AccountScreen.kt @@ -19,38 +19,41 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import app.closer.core.navigation.AppRoute @OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountScreen( - onNavigate: (String) -> Unit = {} + onNavigate: (String) -> Unit = {}, + viewModel: EditProfileViewModel = hiltViewModel() ) { + val snackbar = remember { SnackbarHostState() } + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, containerColor = Color.Transparent, modifier = Modifier.background(SettingsBackgroundBrush), topBar = { @@ -79,68 +82,11 @@ fun AccountScreen( .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Signed-out / local mode state — no fake personal data - Card( + EditProfileContent( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = SettingsCard) - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - Icons.Filled.Person, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = SettingsPrimaryDeep - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = "Local profile", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = SettingsInk - ) - Text( - text = "Your profile is stored on this device. Sign in later to back it up and connect with your partner.", - style = MaterialTheme.typography.bodySmall, - color = SettingsMuted, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - Spacer(Modifier.height(4.dp)) - - // Identity / sync / export — disabled until auth is live - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = SettingsCard) - ) { - Column { - AccountRow( - icon = Icons.Filled.Cloud, - label = "Sign in or create account", - enabled = false, - onClick = { /* Auth coming soon */ } - ) - Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) - AccountRow( - icon = Icons.Filled.Download, - label = "Export your data", - enabled = false, - onClick = { /* Export coming soon */ } - ) - } - } + snackbar = snackbar, + viewModel = viewModel + ) Spacer(Modifier.height(8.dp)) @@ -157,6 +103,8 @@ fun AccountScreen( onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) } ) } + + Spacer(Modifier.height(8.dp)) } } } @@ -201,9 +149,3 @@ private fun AccountRow( } } } - -@Preview -@Composable -fun AccountScreenPreview() { - AccountScreen() -} diff --git a/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt b/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt new file mode 100644 index 00000000..42c96bed --- /dev/null +++ b/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt @@ -0,0 +1,402 @@ +package app.closer.ui.settings + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AddAPhoto +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import java.io.File +import app.closer.ui.auth.authTextFieldColors +import app.closer.ui.settings.SettingsBackgroundBrush +import app.closer.ui.settings.SettingsInk +import app.closer.ui.settings.SettingsMuted +import app.closer.ui.settings.SettingsOnPrimary +import app.closer.ui.settings.SettingsPrimary +import app.closer.ui.settings.SettingsPrimaryDeep + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditProfileScreen( + onNavigate: (String) -> Unit = {}, + viewModel: EditProfileViewModel = hiltViewModel() +) { + val snackbar = remember { SnackbarHostState() } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + containerColor = Color.Transparent, + modifier = Modifier.background(SettingsBackgroundBrush), + topBar = { + TopAppBar( + title = { Text("Edit profile", color = SettingsInk) }, + navigationIcon = { + IconButton(onClick = { onNavigate("back") }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = SettingsInk + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + ) + } + ) { padding -> + EditProfileContent( + modifier = Modifier + .fillMaxSize() + .padding(padding), + snackbar = snackbar, + viewModel = viewModel + ) + } +} + +@Composable +fun EditProfileContent( + modifier: Modifier = Modifier, + snackbar: SnackbarHostState? = null, + viewModel: EditProfileViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + val focusManager = LocalFocusManager.current + val context = LocalContext.current + val localSnackbar = remember { SnackbarHostState() } + val activeSnackbar = snackbar ?: localSnackbar + + LaunchedEffect(state.error) { + state.error?.let { + activeSnackbar.showSnackbar(it) + viewModel.dismissError() + } + } + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri: Uri? -> + uri?.let { viewModel.setPhotoUri(it.toString()) } + } + + val cameraFile = remember(context) { File(context.filesDir, "profile_edit_temp.jpg") } + val cameraUri = remember(cameraFile) { + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + cameraFile + ) + } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + if (success) viewModel.setPhotoUri(cameraUri.toString()) + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) cameraLauncher.launch(cameraUri) + } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state.isLoading) { + Spacer(Modifier.height(64.dp)) + CircularProgressIndicator(color = SettingsPrimary) + } else { + val imageUri = state.photoUri ?: state.photoUrl.takeIf { it.isNotBlank() } + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background(Color.White) + .border(2.dp, SettingsPrimary.copy(alpha = 0.3f), CircleShape) + .clickable { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + contentAlignment = Alignment.Center + ) { + if (imageUri != null) { + AsyncImage( + model = imageUri, + contentDescription = "Profile photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + placeholder = rememberVectorPainter(Icons.Filled.Person), + error = rememberVectorPainter(Icons.Filled.Person) + ) + } else { + Icon( + Icons.Filled.Person, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = SettingsMuted + ) + } + } + + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { + Text("Change photo", color = SettingsPrimaryDeep) + } + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = SettingsInk + ) + ) { + Icon(Icons.Filled.PhotoLibrary, contentDescription = null, modifier = Modifier.padding(end = 8.dp)) + Text("Gallery") + } + OutlinedButton( + onClick = { + when { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> cameraLauncher.launch(cameraUri) + else -> cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = SettingsInk + ) + ) { + Icon(Icons.Filled.AddAPhoto, contentDescription = null, modifier = Modifier.padding(end = 8.dp)) + Text("Camera") + } + } + + Spacer(Modifier.height(24.dp)) + + // Account status card + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = if (state.isGoogleAccount) "Signed in with Google" else if (state.isAnonymous) "Anonymous account" else "Email account", + style = MaterialTheme.typography.labelMedium, + color = SettingsMuted + ) + if (state.email.isNotBlank()) { + Text( + text = state.email, + style = MaterialTheme.typography.bodyLarge, + color = SettingsInk, + fontWeight = FontWeight.Medium + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + + OutlinedTextField( + value = state.displayName, + onValueChange = viewModel::updateDisplayName, + label = { Text("Your name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = authTextFieldColors(), + isError = state.nameError != null, + supportingText = state.nameError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) }) + ) + + Spacer(Modifier.height(20.dp)) + + Text( + text = "Sex", + style = MaterialTheme.typography.labelLarge, + color = SettingsInk, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "We use this to show you the most relevant questions in Desire Sync and other features.", + style = MaterialTheme.typography.bodyMedium, + color = SettingsMuted, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SexEditOption( + label = "Female", + selected = state.sex == "female", + onClick = { viewModel.selectSex("female") }, + modifier = Modifier.weight(1f) + ) + SexEditOption( + label = "Male", + selected = state.sex == "male", + onClick = { viewModel.selectSex("male") }, + modifier = Modifier.weight(1f) + ) + } + state.sexError?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Spacer(Modifier.height(32.dp)) + + Button( + onClick = { focusManager.clearFocus(); viewModel.save() }, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = SettingsPrimary, + contentColor = SettingsOnPrimary + ) + ) { + if (state.isSaving) CircularProgressIndicator(color = SettingsOnPrimary, strokeWidth = 2.dp) + else Text("Save changes", style = MaterialTheme.typography.labelLarge) + } + + Spacer(Modifier.height(24.dp)) + } + } +} + +@Composable +private fun SexEditOption( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val background = if (selected) SettingsPrimary else Color.White + val contentColor = if (selected) SettingsOnPrimary else SettingsInk + val borderColor = if (selected) SettingsPrimary else SettingsMuted.copy(alpha = 0.24f) + + Box( + modifier = modifier + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(background) + .border(1.dp, borderColor, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (selected) { + Icon( + Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(end = 6.dp), + tint = contentColor + ) + } + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = contentColor + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt b/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt new file mode 100644 index 00000000..e15317ca --- /dev/null +++ b/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt @@ -0,0 +1,119 @@ +package app.closer.ui.settings + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.data.remote.FirebaseStorageDataSource +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class EditProfileUiState( + val displayName: String = "", + val sex: String = "", + val photoUrl: String = "", + val photoUri: String? = null, + val email: String = "", + val isAnonymous: Boolean = false, + val isGoogleAccount: Boolean = false, + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val error: String? = null, + val success: Boolean = false, + val nameError: String? = null, + val sexError: String? = null +) + +@HiltViewModel +class EditProfileViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val userRepository: UserRepository, + private val storageDataSource: FirebaseStorageDataSource +) : ViewModel() { + + private val _uiState = MutableStateFlow(EditProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadProfile() + } + + private fun loadProfile() { + val uid = authRepository.currentUserId ?: run { + _uiState.update { it.copy(isLoading = false, error = "Not signed in.") } + return + } + viewModelScope.launch { + val user = runCatching { userRepository.getUser(uid) }.getOrNull() + _uiState.update { + it.copy( + isLoading = false, + displayName = user?.displayName ?: "", + sex = user?.sex ?: "", + photoUrl = user?.photoUrl ?: "", + email = authRepository.currentUserEmail ?: user?.email ?: "", + isAnonymous = authRepository.isAnonymous, + isGoogleAccount = authRepository.isGoogleAccount + ) + } + } + } + + fun updateDisplayName(name: String) = _uiState.update { it.copy(displayName = name, nameError = null, error = null) } + + fun selectSex(sex: String) = _uiState.update { it.copy(sex = sex, sexError = null, error = null) } + + fun setPhotoUri(uri: String?) = _uiState.update { it.copy(photoUri = uri, error = null) } + + fun save() { + val state = _uiState.value + val name = state.displayName.trim() + when { + name.isBlank() -> { + _uiState.update { it.copy(nameError = "Please enter your name.") } + return + } + name.length < 2 -> { + _uiState.update { it.copy(nameError = "Name must be at least 2 characters.") } + return + } + state.sex.isBlank() -> { + _uiState.update { it.copy(sexError = "Please select your sex.") } + return + } + } + val uid = authRepository.currentUserId ?: run { + _uiState.update { it.copy(error = "Not signed in.") } + return + } + _uiState.update { it.copy(isSaving = true, error = null) } + viewModelScope.launch { + runCatching { + val finalPhotoUrl = if (!state.photoUri.isNullOrBlank()) { + storageDataSource.uploadProfilePhoto(uid, Uri.parse(state.photoUri)) + } else { + state.photoUrl + } + userRepository.updateDisplayName(uid, name) + userRepository.updateSex(uid, state.sex) + if (finalPhotoUrl.isNotBlank()) { + userRepository.updatePhotoUrl(uid, finalPhotoUrl) + } + }.onSuccess { + _uiState.update { it.copy(isSaving = false, success = true, photoUri = null, photoUrl = it.photoUrl) } + loadProfile() + }.onFailure { e -> + _uiState.update { it.copy(isSaving = false, error = e.message ?: "Couldn't save changes. Please try again.") } + } + } + } + + fun dismissError() = _uiState.update { it.copy(error = null) } + fun onSuccessHandled() = _uiState.update { it.copy(success = false) } +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..600817c7 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + +