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 @@
+
+
+
+