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")
|
implementation("com.google.android.play:integrity:1.4.0")
|
||||||
|
|
||||||
// Firebase Functions — callable for server-side integrity token verification
|
// Firebase Functions — callable for server-side integrity token verification
|
||||||
|
implementation("com.google.firebase:firebase-storage-ktx")
|
||||||
implementation("com.google.firebase:firebase-functions-ktx")
|
implementation("com.google.firebase:firebase-functions-ktx")
|
||||||
|
|
||||||
// RevenueCat native Android SDK (group: com.revenuecat.purchases, artifact: purchases)
|
// RevenueCat native Android SDK (group: com.revenuecat.purchases, artifact: purchases)
|
||||||
implementation("com.revenuecat.purchases:purchases:8.20.0")
|
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
|
// Google Sign-In via Credential Manager
|
||||||
implementation("androidx.credentials:credentials:1.3.0")
|
implementation("androidx.credentials:credentials:1.3.0")
|
||||||
implementation("androidx.credentials:credentials-play-services-auth: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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CloserApp"
|
android:name=".CloserApp"
|
||||||
|
|
@ -35,6 +36,16 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
import app.closer.domain.model.AuthState
|
import app.closer.domain.model.AuthState
|
||||||
|
import app.closer.domain.model.GoogleSignInResult
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import com.google.firebase.auth.GoogleAuthProvider
|
import com.google.firebase.auth.GoogleAuthProvider
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
|
@ -20,6 +21,9 @@ class FirebaseAuthDataSource @Inject constructor() {
|
||||||
val currentUserId: String? get() = auth.currentUser?.uid
|
val currentUserId: String? get() = auth.currentUser?.uid
|
||||||
val currentUserEmail: String? get() = auth.currentUser?.email
|
val currentUserEmail: String? get() = auth.currentUser?.email
|
||||||
val isSignedIn: Boolean get() = auth.currentUser != null
|
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 {
|
val authState: Flow<AuthState> = callbackFlow {
|
||||||
trySend(snapshot())
|
trySend(snapshot())
|
||||||
|
|
@ -65,11 +69,22 @@ class FirebaseAuthDataSource @Inject constructor() {
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signInWithGoogle(idToken: String): String =
|
suspend fun signInWithGoogle(idToken: String): GoogleSignInResult =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val credential = GoogleAuthProvider.getCredential(idToken, null)
|
val credential = GoogleAuthProvider.getCredential(idToken, null)
|
||||||
auth.signInWithCredential(credential)
|
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) }
|
.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") ?: "",
|
email = snap.getString("email") ?: "",
|
||||||
displayName = snap.getString("displayName") ?: "",
|
displayName = snap.getString("displayName") ?: "",
|
||||||
photoUrl = snap.getString("photoUrl") ?: "",
|
photoUrl = snap.getString("photoUrl") ?: "",
|
||||||
|
sex = snap.getString("sex") ?: "",
|
||||||
partnerId = snap.getString("partnerId"),
|
partnerId = snap.getString("partnerId"),
|
||||||
coupleId = snap.getString("coupleId"),
|
coupleId = snap.getString("coupleId"),
|
||||||
plan = snap.getString("plan") ?: "free",
|
plan = snap.getString("plan") ?: "free",
|
||||||
|
|
@ -43,6 +44,9 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
|
||||||
"email" to user.email,
|
"email" to user.email,
|
||||||
"displayName" to user.displayName,
|
"displayName" to user.displayName,
|
||||||
"photoUrl" to user.photoUrl,
|
"photoUrl" to user.photoUrl,
|
||||||
|
"sex" to user.sex,
|
||||||
|
"partnerId" to user.partnerId,
|
||||||
|
"coupleId" to user.coupleId,
|
||||||
"plan" to user.plan,
|
"plan" to user.plan,
|
||||||
"createdAt" to user.createdAt,
|
"createdAt" to user.createdAt,
|
||||||
"lastActiveAt" to user.lastActiveAt
|
"lastActiveAt" to user.lastActiveAt
|
||||||
|
|
@ -62,6 +66,26 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.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 =
|
suspend fun hasProfile(uid: String): Boolean =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
userRef(uid).get()
|
userRef(uid).get()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package app.closer.data.repository
|
||||||
|
|
||||||
import app.closer.data.remote.FirebaseAuthDataSource
|
import app.closer.data.remote.FirebaseAuthDataSource
|
||||||
import app.closer.domain.model.AuthState
|
import app.closer.domain.model.AuthState
|
||||||
|
import app.closer.domain.model.GoogleSignInResult
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.security.AuthRateLimiter
|
import app.closer.domain.security.AuthRateLimiter
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -18,8 +19,10 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
|
||||||
override val currentUserId: String? get() = dataSource.currentUserId
|
override val currentUserId: String? get() = dataSource.currentUserId
|
||||||
override val currentUserEmail: String? get() = dataSource.currentUserEmail
|
override val currentUserEmail: String? get() = dataSource.currentUserEmail
|
||||||
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
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) {
|
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
|
||||||
runCatching { dataSource.signInWithGoogle(idToken) }
|
runCatching { dataSource.signInWithGoogle(idToken) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ class UserRepositoryImpl @Inject constructor(
|
||||||
override suspend fun updateDisplayName(uid: String, displayName: String) =
|
override suspend fun updateDisplayName(uid: String, displayName: String) =
|
||||||
dataSource.updateDisplayName(uid, displayName)
|
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 hasProfile(uid: String): Boolean = dataSource.hasProfile(uid)
|
||||||
|
|
||||||
override suspend fun storeFcmToken(uid: String, token: String) =
|
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 email: String = "",
|
||||||
val displayName: String = "",
|
val displayName: String = "",
|
||||||
val photoUrl: String = "",
|
val photoUrl: String = "",
|
||||||
|
val sex: String = "",
|
||||||
val partnerId: String? = null,
|
val partnerId: String? = null,
|
||||||
val coupleId: String? = null,
|
val coupleId: String? = null,
|
||||||
val plan: String = "free",
|
val plan: String = "free",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package app.closer.domain.repository
|
package app.closer.domain.repository
|
||||||
|
|
||||||
import app.closer.domain.model.AuthState
|
import app.closer.domain.model.AuthState
|
||||||
|
import app.closer.domain.model.GoogleSignInResult
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface AuthRepository {
|
interface AuthRepository {
|
||||||
|
|
@ -8,7 +9,9 @@ interface AuthRepository {
|
||||||
val currentUserId: String?
|
val currentUserId: String?
|
||||||
val currentUserEmail: String?
|
val currentUserEmail: String?
|
||||||
val isSignedIn: Boolean
|
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 signInAnonymously(): Result<String>
|
||||||
suspend fun signInWithEmail(email: String, password: String): Result<String>
|
suspend fun signInWithEmail(email: String, password: String): Result<String>
|
||||||
suspend fun signUpWithEmail(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 getUser(uid: String): User?
|
||||||
suspend fun createUser(user: User)
|
suspend fun createUser(user: User)
|
||||||
suspend fun updateDisplayName(uid: String, displayName: String)
|
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 hasProfile(uid: String): Boolean
|
||||||
suspend fun storeFcmToken(uid: String, token: String)
|
suspend fun storeFcmToken(uid: String, token: String)
|
||||||
suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata)
|
suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata)
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ fun LoginScreen(
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(state.success) {
|
LaunchedEffect(state.success) {
|
||||||
if (state.success) onNavigate(AppRoute.HOME)
|
if (state.success) onNavigate(AppRoute.ONBOARDING)
|
||||||
}
|
}
|
||||||
LaunchedEffect(state.error) {
|
LaunchedEffect(state.error) {
|
||||||
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ package app.closer.ui.auth
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.AuthRepository
|
||||||
|
import app.closer.domain.repository.UserRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -22,7 +25,8 @@ data class LoginUiState(
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LoginViewModel @Inject constructor(
|
class LoginViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository
|
private val authRepository: AuthRepository,
|
||||||
|
private val userRepository: UserRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(LoginUiState())
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
|
|
@ -43,7 +47,15 @@ class LoginViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun signInWithGoogle(idToken: String) {
|
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() {
|
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 {
|
private fun friendlyError(e: Throwable): String = when {
|
||||||
e.message?.contains("no user record") == true -> "No account found with that email."
|
e.message?.contains("no user record") == true -> "No account found with that email."
|
||||||
e.message?.contains("password is invalid") == true -> "Incorrect password."
|
e.message?.contains("password is invalid") == true -> "Incorrect password."
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
package app.closer.ui.onboarding
|
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.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.ui.auth.AuthBackgroundBrush
|
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.AuthMuted
|
||||||
import app.closer.ui.auth.AuthOnPrimary
|
import app.closer.ui.auth.AuthOnPrimary
|
||||||
import app.closer.ui.auth.AuthPrimary
|
import app.closer.ui.auth.AuthPrimary
|
||||||
|
import app.closer.ui.auth.AuthPrimaryDeep
|
||||||
import app.closer.ui.auth.authTextFieldColors
|
import app.closer.ui.auth.authTextFieldColors
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateProfileScreen(
|
fun CreateProfileScreen(
|
||||||
onNavigate: (String) -> Unit = {},
|
onNavigate: (String) -> Unit = {},
|
||||||
|
|
@ -54,6 +90,7 @@ fun CreateProfileScreen(
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val snackbar = remember { SnackbarHostState() }
|
val snackbar = remember { SnackbarHostState() }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(state.success) {
|
LaunchedEffect(state.success) {
|
||||||
if (state.success) onNavigate(AppRoute.HOME)
|
if (state.success) onNavigate(AppRoute.HOME)
|
||||||
|
|
@ -62,10 +99,54 @@ fun CreateProfileScreen(
|
||||||
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
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(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbar) },
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
containerColor = Color.Transparent,
|
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 ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -80,6 +161,54 @@ fun CreateProfileScreen(
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(48.dp))
|
Spacer(Modifier.height(48.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Step ${state.currentStep.ordinal + 1} of 3",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = AuthMuted,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NameStep(
|
||||||
|
state: CreateProfileUiState,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onContinue: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = "What should your\npartner call you?",
|
text = "What should your\npartner call you?",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
|
@ -98,7 +227,7 @@ fun CreateProfileScreen(
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.displayName,
|
value = state.displayName,
|
||||||
onValueChange = viewModel::updateDisplayName,
|
onValueChange = onNameChange,
|
||||||
label = { Text("Your name") },
|
label = { Text("Your name") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -110,13 +239,13 @@ fun CreateProfileScreen(
|
||||||
keyboardType = KeyboardType.Text,
|
keyboardType = KeyboardType.Text,
|
||||||
imeAction = ImeAction.Done
|
imeAction = ImeAction.Done
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.saveProfile() })
|
keyboardActions = KeyboardActions(onDone = { onContinue() })
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { focusManager.clearFocus(); viewModel.saveProfile() },
|
onClick = onContinue,
|
||||||
enabled = !state.isLoading,
|
enabled = !state.isLoading,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
|
@ -127,8 +256,190 @@ fun CreateProfileScreen(
|
||||||
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
|
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
|
||||||
else Text("Continue", style = MaterialTheme.typography.labelLarge)
|
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))
|
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
|
package app.closer.ui.onboarding
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.data.remote.FirebaseStorageDataSource
|
||||||
import app.closer.domain.model.User
|
import app.closer.domain.model.User
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.repository.UserRepository
|
import app.closer.domain.repository.UserRepository
|
||||||
|
|
@ -13,50 +15,133 @@ import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
enum class ProfileStep {
|
||||||
|
NAME,
|
||||||
|
SEX,
|
||||||
|
PHOTO
|
||||||
|
}
|
||||||
|
|
||||||
data class CreateProfileUiState(
|
data class CreateProfileUiState(
|
||||||
|
val currentStep: ProfileStep = ProfileStep.NAME,
|
||||||
val displayName: String = "",
|
val displayName: String = "",
|
||||||
val nameError: String? = null,
|
val sex: String = "",
|
||||||
|
val photoUrl: String = "",
|
||||||
|
val photoUri: String? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val success: Boolean = false
|
val success: Boolean = false,
|
||||||
|
val nameError: String? = null,
|
||||||
|
val sexError: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CreateProfileViewModel @Inject constructor(
|
class CreateProfileViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val userRepository: UserRepository
|
private val userRepository: UserRepository,
|
||||||
|
private val storageDataSource: FirebaseStorageDataSource
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(CreateProfileUiState())
|
private val _uiState = MutableStateFlow(CreateProfileUiState())
|
||||||
val uiState: StateFlow<CreateProfileUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<CreateProfileUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun updateDisplayName(name: String) = _uiState.update { it.copy(displayName = name, nameError = null, error = null) }
|
init {
|
||||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
loadExistingProfile()
|
||||||
|
}
|
||||||
fun saveProfile() {
|
|
||||||
val name = _uiState.value.displayName.trim()
|
private fun loadExistingProfile() {
|
||||||
if (name.isBlank()) {
|
val uid = authRepository.currentUserId ?: return
|
||||||
_uiState.update { it.copy(nameError = "Please enter your name.") }
|
viewModelScope.launch {
|
||||||
return
|
val user = runCatching { userRepository.getUser(uid) }.getOrNull() ?: return@launch
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
displayName = user.displayName,
|
||||||
|
sex = user.sex,
|
||||||
|
photoUrl = user.photoUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (name.length < 2) {
|
|
||||||
_uiState.update { it.copy(nameError = "Name must be at least 2 characters.") }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val uid = authRepository.currentUserId ?: run {
|
val uid = authRepository.currentUserId ?: run {
|
||||||
_uiState.update { it.copy(error = "Not signed in. Please sign in and try again.") }
|
_uiState.update { it.copy(error = "Not signed in. Please sign in and try again.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isLoading = true, nameError = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
val finalPhotoUrl = if (!skipPhoto && !state.photoUri.isNullOrBlank()) {
|
||||||
|
storageDataSource.uploadProfilePhoto(uid, Uri.parse(state.photoUri))
|
||||||
|
} else {
|
||||||
|
state.photoUrl
|
||||||
|
}
|
||||||
val existing = userRepository.getUser(uid)
|
val existing = userRepository.getUser(uid)
|
||||||
if (existing == null) {
|
val user = existing ?: User(
|
||||||
userRepository.createUser(
|
id = uid,
|
||||||
User(id = uid, displayName = name, createdAt = System.currentTimeMillis(), lastActiveAt = System.currentTimeMillis())
|
displayName = name,
|
||||||
|
sex = state.sex,
|
||||||
|
photoUrl = finalPhotoUrl,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
lastActiveAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
if (existing == null) {
|
||||||
|
userRepository.createUser(user.copy(displayName = name, sex = state.sex, photoUrl = finalPhotoUrl))
|
||||||
} else {
|
} else {
|
||||||
userRepository.updateDisplayName(uid, name)
|
userRepository.updateDisplayName(uid, name)
|
||||||
|
userRepository.updateSex(uid, state.sex)
|
||||||
|
if (finalPhotoUrl.isNotBlank()) {
|
||||||
|
userRepository.updatePhotoUrl(uid, finalPhotoUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
_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) }
|
.onFailure { Log.w(TAG, "Could not load user profile during onboarding", it) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
val destination = when {
|
val destination = when {
|
||||||
user == null || user.displayName.isBlank() -> "create_profile"
|
user == null || user.displayName.isBlank() || user.sex.isBlank() -> "create_profile"
|
||||||
else -> "home"
|
else -> "home"
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isCheckingAuth = false, navigateTo = destination) }
|
_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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Download
|
|
||||||
import androidx.compose.material.icons.filled.Person
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AccountScreen(
|
fun AccountScreen(
|
||||||
onNavigate: (String) -> Unit = {}
|
onNavigate: (String) -> Unit = {},
|
||||||
|
viewModel: EditProfileViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
modifier = Modifier.background(SettingsBackgroundBrush),
|
modifier = Modifier.background(SettingsBackgroundBrush),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -79,68 +82,11 @@ fun AccountScreen(
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// Signed-out / local mode state — no fake personal data
|
EditProfileContent(
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(16.dp),
|
snackbar = snackbar,
|
||||||
colors = CardDefaults.cardColors(containerColor = SettingsCard)
|
viewModel = viewModel
|
||||||
) {
|
|
||||||
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 */ }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
|
@ -157,6 +103,8 @@ fun AccountScreen(
|
||||||
onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) }
|
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