feat: add sex field to user profile, Google profile extraction, multi-step onboarding, profile editing
This commit is contained in:
parent
f7b95fc9ba
commit
92a257b3eb
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue