feat(auth): Firebase auth wiring, login/signup/forgot-password with ViewModels
- Added FirebaseAuthDataSource, FirestoreUserDataSource - AuthRepository + UserRepository interfaces and Firebase impls - LoginViewModel, SignUpViewModel, ForgotPasswordViewModel - OnboardingViewModel, CreateProfileViewModel - Updated LoginScreen, SignUpScreen, ForgotPasswordScreen, OnboardingScreen, CreateProfileScreen with ViewModel-backed state - Wired RepositoryModule with Firebase auth dependencies
This commit is contained in:
parent
af7603d61c
commit
8bcb3308c1
|
|
@ -57,6 +57,7 @@ dependencies {
|
|||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package com.couplesconnect.app.data.remote
|
||||
|
||||
import com.couplesconnect.app.domain.model.AuthState
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Singleton
|
||||
class FirebaseAuthDataSource @Inject constructor() {
|
||||
|
||||
private val auth = FirebaseAuth.getInstance()
|
||||
|
||||
val currentUserId: String? get() = auth.currentUser?.uid
|
||||
val isSignedIn: Boolean get() = auth.currentUser != null
|
||||
|
||||
val authState: Flow<AuthState> = callbackFlow {
|
||||
trySend(snapshot())
|
||||
val listener = FirebaseAuth.AuthStateListener { fa ->
|
||||
trySend(
|
||||
fa.currentUser?.let { AuthState.Authenticated(it.uid, it.isAnonymous) }
|
||||
?: AuthState.Unauthenticated
|
||||
)
|
||||
}
|
||||
auth.addAuthStateListener(listener)
|
||||
awaitClose { auth.removeAuthStateListener(listener) }
|
||||
}
|
||||
|
||||
private fun snapshot(): AuthState =
|
||||
auth.currentUser?.let { AuthState.Authenticated(it.uid, it.isAnonymous) }
|
||||
?: AuthState.Unauthenticated
|
||||
|
||||
suspend fun signInAnonymously(): String =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
auth.signInAnonymously()
|
||||
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
suspend fun signInWithEmail(email: String, password: String): String =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
auth.signInWithEmailAndPassword(email, password)
|
||||
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
suspend fun signUpWithEmail(email: String, password: String): String =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
auth.createUserWithEmailAndPassword(email, password)
|
||||
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
suspend fun sendPasswordResetEmail(email: String): Unit =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
auth.sendPasswordResetEmail(email)
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
fun signOut() = auth.signOut()
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.couplesconnect.app.data.remote
|
||||
|
||||
import com.couplesconnect.app.domain.model.User
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.google.firebase.firestore.SetOptions
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Singleton
|
||||
class FirestoreUserDataSource @Inject constructor() {
|
||||
|
||||
private val db = FirebaseFirestore.getInstance()
|
||||
private fun userRef(uid: String) = db.collection("users").document(uid)
|
||||
|
||||
suspend fun getUser(uid: String): User? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
userRef(uid).get()
|
||||
.addOnSuccessListener { snap ->
|
||||
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
|
||||
cont.resume(
|
||||
User(
|
||||
id = snap.id,
|
||||
email = snap.getString("email") ?: "",
|
||||
displayName = snap.getString("displayName") ?: "",
|
||||
photoUrl = snap.getString("photoUrl") ?: "",
|
||||
partnerId = snap.getString("partnerId"),
|
||||
coupleId = snap.getString("coupleId"),
|
||||
plan = snap.getString("plan") ?: "free",
|
||||
createdAt = snap.getLong("createdAt") ?: 0L,
|
||||
lastActiveAt = snap.getLong("lastActiveAt") ?: 0L
|
||||
)
|
||||
)
|
||||
}
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
suspend fun createUser(user: User): Unit =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
userRef(user.id).set(
|
||||
mapOf(
|
||||
"email" to user.email,
|
||||
"displayName" to user.displayName,
|
||||
"photoUrl" to user.photoUrl,
|
||||
"plan" to user.plan,
|
||||
"createdAt" to user.createdAt,
|
||||
"lastActiveAt" to user.lastActiveAt
|
||||
)
|
||||
)
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
suspend fun updateDisplayName(uid: String, displayName: String): Unit =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
userRef(uid).set(
|
||||
mapOf("displayName" to displayName, "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()
|
||||
.addOnSuccessListener { snap ->
|
||||
val name = snap.getString("displayName") ?: ""
|
||||
cont.resume(snap.exists() && name.isNotBlank())
|
||||
}
|
||||
.addOnFailureListener { cont.resume(false) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.couplesconnect.app.data.repository
|
||||
|
||||
import com.couplesconnect.app.data.remote.FirebaseAuthDataSource
|
||||
import com.couplesconnect.app.domain.model.AuthState
|
||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FirebaseAuthRepositoryImpl @Inject constructor(
|
||||
private val dataSource: FirebaseAuthDataSource
|
||||
) : AuthRepository {
|
||||
|
||||
override val authState: Flow<AuthState> = dataSource.authState
|
||||
override val currentUserId: String? get() = dataSource.currentUserId
|
||||
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
||||
|
||||
override suspend fun signInAnonymously(): Result<String> =
|
||||
runCatching { dataSource.signInAnonymously() }
|
||||
|
||||
override suspend fun signInWithEmail(email: String, password: String): Result<String> =
|
||||
runCatching { dataSource.signInWithEmail(email, password) }
|
||||
|
||||
override suspend fun signUpWithEmail(email: String, password: String): Result<String> =
|
||||
runCatching { dataSource.signUpWithEmail(email, password) }
|
||||
|
||||
override suspend fun sendPasswordResetEmail(email: String): Result<Unit> =
|
||||
runCatching { dataSource.sendPasswordResetEmail(email) }
|
||||
|
||||
override suspend fun signOut() = dataSource.signOut()
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.couplesconnect.app.data.repository
|
||||
|
||||
import com.couplesconnect.app.data.remote.FirestoreUserDataSource
|
||||
import com.couplesconnect.app.domain.model.User
|
||||
import com.couplesconnect.app.domain.repository.UserRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UserRepositoryImpl @Inject constructor(
|
||||
private val dataSource: FirestoreUserDataSource
|
||||
) : UserRepository {
|
||||
|
||||
override suspend fun getUser(uid: String): User? = dataSource.getUser(uid)
|
||||
|
||||
override suspend fun createUser(user: User) = dataSource.createUser(user)
|
||||
|
||||
override suspend fun updateDisplayName(uid: String, displayName: String) =
|
||||
dataSource.updateDisplayName(uid, displayName)
|
||||
|
||||
override suspend fun hasProfile(uid: String): Boolean = dataSource.hasProfile(uid)
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
package com.couplesconnect.app.di
|
||||
|
||||
import com.couplesconnect.app.data.repository.FirebaseAuthRepositoryImpl
|
||||
import com.couplesconnect.app.data.repository.SharedPreferencesLocalAnswerRepository
|
||||
import com.couplesconnect.app.data.repository.RoomQuestionRepository
|
||||
import com.couplesconnect.app.data.repository.QuestionThreadRepositoryImpl
|
||||
import com.couplesconnect.app.data.repository.UserRepositoryImpl
|
||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
|
||||
import com.couplesconnect.app.domain.repository.QuestionRepository
|
||||
import com.couplesconnect.app.domain.repository.QuestionThreadRepository
|
||||
import com.couplesconnect.app.domain.repository.UserRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -16,21 +20,18 @@ import javax.inject.Singleton
|
|||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindQuestionThreadRepository(
|
||||
impl: QuestionThreadRepositoryImpl
|
||||
): QuestionThreadRepository
|
||||
@Binds @Singleton
|
||||
abstract fun bindAuthRepository(impl: FirebaseAuthRepositoryImpl): AuthRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindQuestionRepository(
|
||||
impl: RoomQuestionRepository
|
||||
): QuestionRepository
|
||||
@Binds @Singleton
|
||||
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalAnswerRepository(
|
||||
impl: SharedPreferencesLocalAnswerRepository
|
||||
): LocalAnswerRepository
|
||||
@Binds @Singleton
|
||||
abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindQuestionRepository(impl: RoomQuestionRepository): QuestionRepository
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.couplesconnect.app.domain.model
|
||||
|
||||
sealed class AuthState {
|
||||
object Loading : AuthState()
|
||||
data class Authenticated(val userId: String, val isAnonymous: Boolean) : AuthState()
|
||||
object Unauthenticated : AuthState()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.couplesconnect.app.domain.repository
|
||||
|
||||
import com.couplesconnect.app.domain.model.AuthState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AuthRepository {
|
||||
val authState: Flow<AuthState>
|
||||
val currentUserId: String?
|
||||
val isSignedIn: Boolean
|
||||
suspend fun signInAnonymously(): Result<String>
|
||||
suspend fun signInWithEmail(email: String, password: String): Result<String>
|
||||
suspend fun signUpWithEmail(email: String, password: String): Result<String>
|
||||
suspend fun sendPasswordResetEmail(email: String): Result<Unit>
|
||||
suspend fun signOut()
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.couplesconnect.app.domain.repository
|
||||
|
||||
import com.couplesconnect.app.domain.model.User
|
||||
|
||||
interface UserRepository {
|
||||
suspend fun getUser(uid: String): User?
|
||||
suspend fun createUser(user: User)
|
||||
suspend fun updateDisplayName(uid: String, displayName: String)
|
||||
suspend fun hasProfile(uid: String): Boolean
|
||||
}
|
||||
|
|
@ -1,36 +1,145 @@
|
|||
package com.couplesconnect.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.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.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.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: ForgotPasswordViewModel = hiltViewModel()
|
||||
) {
|
||||
PlaceholderScreen(
|
||||
title = "Find your way back",
|
||||
section = "Auth",
|
||||
description = "A recovery surface for password reset and account access help once auth is enabled.",
|
||||
route = AppRoute.FORGOT_PASSWORD,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFFF2A65A),
|
||||
primaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
|
||||
secondaryAction = PlaceholderAction("Create account", AppRoute.SIGN_UP),
|
||||
chips = listOf("Recovery", "Low stress", "Return path"),
|
||||
details = listOf(
|
||||
"Reset guidance can stay simple and reassuring",
|
||||
"The user can return to login without friction",
|
||||
"Recovery copy can stay calm and specific"
|
||||
)
|
||||
)
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ForgotPasswordScreenPreview() {
|
||||
ForgotPasswordScreen()
|
||||
LaunchedEffect(state.error) {
|
||||
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
if (state.sent) {
|
||||
Spacer(Modifier.height(48.dp))
|
||||
Text("✓", style = MaterialTheme.typography.displayMedium, color = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Reset email sent", style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Check your inbox and follow the link to reset your password.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
|
||||
Text("Back to sign in", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"Reset your access",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Enter your email and we'll send a reset link.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::updateEmail,
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.sendReset() })
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = { focusManager.clearFocus(); viewModel.sendReset() },
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
if (state.isLoading) CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
else Text("Send reset email", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.couplesconnect.app.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||
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 ForgotPasswordUiState(
|
||||
val email: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val sent: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ForgotPasswordViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ForgotPasswordUiState())
|
||||
val uiState: StateFlow<ForgotPasswordUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun updateEmail(v: String) = _uiState.update { it.copy(email = v, error = null) }
|
||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||
|
||||
fun sendReset() {
|
||||
val email = _uiState.value.email.trim()
|
||||
if (email.isBlank()) {
|
||||
_uiState.update { it.copy(error = "Please enter your email address.") }
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
authRepository.sendPasswordResetEmail(email)
|
||||
.onSuccess { _uiState.update { it.copy(isLoading = false, sent = true) } }
|
||||
.onFailure { e ->
|
||||
val msg = when {
|
||||
e.message?.contains("no user record") == true -> "No account found with that email."
|
||||
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
|
||||
else -> e.message ?: "Something went wrong. Please try again."
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, error = msg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,176 @@
|
|||
package com.couplesconnect.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.safeDrawingPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: LoginViewModel = hiltViewModel()
|
||||
) {
|
||||
PlaceholderScreen(
|
||||
title = "Return to the room",
|
||||
section = "Auth",
|
||||
description = "A calm return point for partners who already have a place in the app.",
|
||||
route = AppRoute.LOGIN,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF6C8EA4),
|
||||
primaryAction = PlaceholderAction("Create account", AppRoute.SIGN_UP),
|
||||
secondaryAction = PlaceholderAction("Reset access", AppRoute.FORGOT_PASSWORD),
|
||||
chips = listOf("Returning", "Account", "Recovery"),
|
||||
details = listOf(
|
||||
"Email and provider choices have a natural home",
|
||||
"Recovery stays close without feeling alarming",
|
||||
"Onboarding can hand returning users here"
|
||||
)
|
||||
)
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LoginScreenPreview() {
|
||||
LoginScreen()
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onNavigate(AppRoute.HOME)
|
||||
}
|
||||
LaunchedEffect(state.error) {
|
||||
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
||||
}
|
||||
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "Welcome back",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Sign in to reconnect with your partner.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::updateEmail,
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::updatePassword,
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = if (state.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.signIn() }),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
||||
Icon(
|
||||
imageVector = if (state.isPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (state.isPasswordVisible) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextButton(
|
||||
onClick = { onNavigate(AppRoute.FORGOT_PASSWORD) },
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Forgot password?", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Button(
|
||||
onClick = { focusManager.clearFocus(); viewModel.signIn() },
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
if (state.isLoading) CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
|
||||
else Text("Sign in", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = viewModel::signInAnonymously,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
Text("Try without account", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
TextButton(onClick = { onNavigate(AppRoute.SIGN_UP) }) {
|
||||
Text(
|
||||
"Don't have an account? Sign up",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.couplesconnect.app.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||
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 LoginUiState(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val isPasswordVisible: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val success: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LoginUiState())
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun updateEmail(email: String) = _uiState.update { it.copy(email = email, error = null) }
|
||||
fun updatePassword(pw: String) = _uiState.update { it.copy(password = pw, error = null) }
|
||||
fun togglePasswordVisibility() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
|
||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||
|
||||
fun signIn() {
|
||||
val state = _uiState.value
|
||||
if (state.email.isBlank() || state.password.isBlank()) {
|
||||
_uiState.update { it.copy(error = "Please enter your email and password.") }
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
authRepository.signInWithEmail(state.email.trim(), state.password)
|
||||
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
||||
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun signInAnonymously() {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
authRepository.signInAnonymously()
|
||||
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
||||
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
|
||||
e.message?.contains("network") == true -> "Check your connection and try again."
|
||||
else -> e.message ?: "Something went wrong. Please try again."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,177 @@
|
|||
package com.couplesconnect.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.safeDrawingPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
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.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.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SignUpScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
PlaceholderScreen(
|
||||
title = "Make a quiet account",
|
||||
section = "Auth",
|
||||
description = "An account creation step with room for email, providers, and consent language.",
|
||||
route = AppRoute.SIGN_UP,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF81B29A),
|
||||
primaryAction = PlaceholderAction("Create profile", AppRoute.CREATE_PROFILE),
|
||||
secondaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
|
||||
chips = listOf("Sign up", "Consent", "Profile next"),
|
||||
details = listOf(
|
||||
"Account creation can stay clear and low-pressure",
|
||||
"Profile setup remains the next step",
|
||||
"Returning users can move back to sign in"
|
||||
)
|
||||
)
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SignUpScreenPreview() {
|
||||
SignUpScreen()
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onNavigate(AppRoute.CREATE_PROFILE)
|
||||
}
|
||||
LaunchedEffect(state.error) {
|
||||
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Create your account",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "You'll set your name after signing up.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::updateEmail,
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::updatePassword,
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = if (state.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
||||
Icon(
|
||||
if (state.isPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingText = { Text("At least 6 characters", style = MaterialTheme.typography.bodySmall) }
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.confirmPassword,
|
||||
onValueChange = viewModel::updateConfirmPassword,
|
||||
label = { Text("Confirm password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = if (state.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.signUp() })
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
Button(
|
||||
onClick = { focusManager.clearFocus(); viewModel.signUp() },
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
if (state.isLoading) CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
|
||||
else Text("Create account", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
|
||||
Text(
|
||||
"Already have an account? Sign in",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.couplesconnect.app.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||
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 SignUpUiState(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val confirmPassword: String = "",
|
||||
val isPasswordVisible: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val success: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SignUpViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SignUpUiState())
|
||||
val uiState: StateFlow<SignUpUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun updateEmail(v: String) = _uiState.update { it.copy(email = v, error = null) }
|
||||
fun updatePassword(v: String) = _uiState.update { it.copy(password = v, error = null) }
|
||||
fun updateConfirmPassword(v: String) = _uiState.update { it.copy(confirmPassword = v, error = null) }
|
||||
fun togglePasswordVisibility() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
|
||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||
|
||||
fun signUp() {
|
||||
val state = _uiState.value
|
||||
when {
|
||||
state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return }
|
||||
state.password.length < 6 -> { _uiState.update { it.copy(error = "Password must be at least 6 characters.") }; return }
|
||||
state.password != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return }
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
authRepository.signUpWithEmail(state.email.trim(), state.password)
|
||||
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
||||
.onFailure { e ->
|
||||
val msg = when {
|
||||
e.message?.contains("email address is already") == true -> "An account with this email already exists."
|
||||
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
|
||||
else -> e.message ?: "Something went wrong. Please try again."
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, error = msg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,118 @@
|
|||
package com.couplesconnect.app.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.safeDrawingPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.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.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@Composable
|
||||
fun CreateProfileScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: CreateProfileViewModel = hiltViewModel()
|
||||
) {
|
||||
PlaceholderScreen(
|
||||
title = "Shape your presence",
|
||||
section = "Onboarding",
|
||||
description = "The future profile setup step for names, pronouns, reminders, and relationship context.",
|
||||
route = AppRoute.CREATE_PROFILE,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF81B29A),
|
||||
primaryAction = PlaceholderAction("Continue home", AppRoute.HOME),
|
||||
secondaryAction = PlaceholderAction("Pair partner", AppRoute.CREATE_INVITE),
|
||||
chips = listOf("Profile", "Private by default", "Pairing ready"),
|
||||
details = listOf(
|
||||
"Personal details can live here before account persistence",
|
||||
"Partner invite is one tap away from the setup path",
|
||||
"Home remains reachable for local preview"
|
||||
)
|
||||
)
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun CreateProfileScreenPreview() {
|
||||
CreateProfileScreen()
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onNavigate(AppRoute.CREATE_INVITE)
|
||||
}
|
||||
LaunchedEffect(state.error) {
|
||||
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
|
||||
}
|
||||
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "What should your\npartner call you?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
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 = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.displayName,
|
||||
onValueChange = viewModel::updateDisplayName,
|
||||
label = { Text("Your name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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 = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
if (state.isLoading) CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
|
||||
else Text("Continue", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package com.couplesconnect.app.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.couplesconnect.app.domain.model.User
|
||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||
import com.couplesconnect.app.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 CreateProfileUiState(
|
||||
val displayName: String = "",
|
||||
val nameError: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val success: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CreateProfileViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository
|
||||
) : 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) }
|
||||
|
||||
fun saveProfile() {
|
||||
val name = _uiState.value.displayName.trim()
|
||||
if (name.isBlank()) {
|
||||
_uiState.update { it.copy(nameError = "Please enter your name.") }
|
||||
return
|
||||
}
|
||||
if (name.length < 2) {
|
||||
_uiState.update { it.copy(nameError = "Name must be at least 2 characters.") }
|
||||
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) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val existing = userRepository.getUser(uid)
|
||||
if (existing == null) {
|
||||
userRepository.createUser(
|
||||
User(id = uid, displayName = name, createdAt = System.currentTimeMillis(), lastActiveAt = System.currentTimeMillis())
|
||||
)
|
||||
} else {
|
||||
userRepository.updateDisplayName(uid, name)
|
||||
}
|
||||
}.onSuccess {
|
||||
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||
}.onFailure { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,124 @@
|
|||
package com.couplesconnect.app.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: OnboardingViewModel = hiltViewModel()
|
||||
) {
|
||||
PlaceholderScreen(
|
||||
title = "Start together",
|
||||
section = "Onboarding",
|
||||
description = "A soft first run for setting names, rhythms, and the kind of connection you want to practice.",
|
||||
route = AppRoute.ONBOARDING,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFFE07A5F),
|
||||
primaryAction = PlaceholderAction("Create profile", AppRoute.CREATE_PROFILE),
|
||||
secondaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
|
||||
chips = listOf("Warm entry", "Shared intent", "Slow start"),
|
||||
details = listOf(
|
||||
"A welcoming first screen with room for brand motion",
|
||||
"Profile setup continues without creating an account yet",
|
||||
"Returning users can move to sign in"
|
||||
)
|
||||
)
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OnboardingScreenPreview() {
|
||||
OnboardingScreen()
|
||||
LaunchedEffect(state.navigateTo) {
|
||||
state.navigateTo?.let { dest ->
|
||||
onNavigate(dest)
|
||||
viewModel.onNavigated()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(Color(0xFFFFF8F5), Color(0xFFF5EDE8)),
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(0f, Float.POSITIVE_INFINITY)
|
||||
)
|
||||
)
|
||||
) {
|
||||
if (state.isCheckingAuth) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.align(Alignment.Center),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Spacer(Modifier.height(1.dp))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.height(80.dp))
|
||||
Text(
|
||||
text = "♡",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Closer",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Questions that bring you closer,\none answer at a time.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(bottom = 48.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { onNavigate(AppRoute.SIGN_UP) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Text("Get started", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
|
||||
Text(
|
||||
"I already have an account",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package com.couplesconnect.app.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.couplesconnect.app.domain.model.AuthState
|
||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||
import com.couplesconnect.app.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 OnboardingUiState(
|
||||
val isCheckingAuth: Boolean = true,
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(OnboardingUiState())
|
||||
val uiState: StateFlow<OnboardingUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
authRepository.authState.collect { authState ->
|
||||
when (authState) {
|
||||
is AuthState.Loading -> _uiState.update { it.copy(isCheckingAuth = true) }
|
||||
is AuthState.Unauthenticated -> _uiState.update { it.copy(isCheckingAuth = false, navigateTo = null) }
|
||||
is AuthState.Authenticated -> {
|
||||
val hasProfile = runCatching { userRepository.hasProfile(authState.userId) }.getOrDefault(false)
|
||||
val destination = if (hasProfile) "home" else "create_profile"
|
||||
_uiState.update { it.copy(isCheckingAuth = false, navigateTo = destination) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
|
||||
}
|
||||
Loading…
Reference in New Issue