feat: add sex field to user profile, Google profile extraction, multi-step onboarding, profile editing

This commit is contained in:
null 2026-06-17 23:59:46 -05:00
parent f7b95fc9ba
commit 92a257b3eb
20 changed files with 1142 additions and 138 deletions

View File

@ -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")

View File

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".CloserApp"
@ -35,6 +36,16 @@
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -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<AuthState> = 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) }
}

View File

@ -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) }
}
}

View File

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

View File

@ -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<String> =
override suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult> =
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
runCatching { dataSource.signInWithGoogle(idToken) }
}

View File

@ -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) =

View File

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

View File

@ -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",

View File

@ -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<String>
val isAnonymous: Boolean
val isGoogleAccount: Boolean
suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult>
suspend fun signInAnonymously(): Result<String>
suspend fun signInWithEmail(email: String, password: String): Result<String>
suspend fun signUpWithEmail(email: String, password: String): Result<String>

View File

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

View File

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

View File

@ -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."

View File

@ -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)
}
}
}

View File

@ -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<CreateProfileUiState> = _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) }
}

View File

@ -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) }

View File

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

View File

@ -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
)
}
}
}

View File

@ -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<EditProfileUiState> = _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) }
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="profile_photos" path="." />
</paths>