diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 925376e6..314ac64a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/couplesconnect/app/data/remote/FirebaseAuthDataSource.kt b/app/src/main/java/com/couplesconnect/app/data/remote/FirebaseAuthDataSource.kt new file mode 100644 index 00000000..3421cbdd --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/remote/FirebaseAuthDataSource.kt @@ -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 = 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() +} diff --git a/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreUserDataSource.kt new file mode 100644 index 00000000..b27473e0 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreUserDataSource.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/FirebaseAuthRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/FirebaseAuthRepositoryImpl.kt new file mode 100644 index 00000000..31c6453a --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/repository/FirebaseAuthRepositoryImpl.kt @@ -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 = dataSource.authState + override val currentUserId: String? get() = dataSource.currentUserId + override val isSignedIn: Boolean get() = dataSource.isSignedIn + + override suspend fun signInAnonymously(): Result = + runCatching { dataSource.signInAnonymously() } + + override suspend fun signInWithEmail(email: String, password: String): Result = + runCatching { dataSource.signInWithEmail(email, password) } + + override suspend fun signUpWithEmail(email: String, password: String): Result = + runCatching { dataSource.signUpWithEmail(email, password) } + + override suspend fun sendPasswordResetEmail(email: String): Result = + runCatching { dataSource.sendPasswordResetEmail(email) } + + override suspend fun signOut() = dataSource.signOut() +} diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/UserRepositoryImpl.kt new file mode 100644 index 00000000..6e8c9dea --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/repository/UserRepositoryImpl.kt @@ -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) +} diff --git a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt index e37b064e..caf68c29 100644 --- a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt @@ -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 } diff --git a/app/src/main/java/com/couplesconnect/app/domain/model/AuthState.kt b/app/src/main/java/com/couplesconnect/app/domain/model/AuthState.kt new file mode 100644 index 00000000..e924f2ff --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/domain/model/AuthState.kt @@ -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() +} diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/AuthRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/AuthRepository.kt new file mode 100644 index 00000000..5059f0f4 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/domain/repository/AuthRepository.kt @@ -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 + val currentUserId: String? + val isSignedIn: Boolean + suspend fun signInAnonymously(): Result + suspend fun signInWithEmail(email: String, password: String): Result + suspend fun signUpWithEmail(email: String, password: String): Result + suspend fun sendPasswordResetEmail(email: String): Result + suspend fun signOut() +} diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/UserRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/UserRepository.kt new file mode 100644 index 00000000..ed33d5c2 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/domain/repository/UserRepository.kt @@ -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 +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/auth/ForgotPasswordScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/auth/ForgotPasswordScreen.kt index 2656be4b..383fcad6 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/auth/ForgotPasswordScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/auth/ForgotPasswordScreen.kt @@ -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) + } + } + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/auth/ForgotPasswordViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/auth/ForgotPasswordViewModel.kt new file mode 100644 index 00000000..b7e88aaa --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/auth/ForgotPasswordViewModel.kt @@ -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 = _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) } + } + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/auth/LoginScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/auth/LoginScreen.kt index 25965d13..8d4f60a0 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/auth/LoginScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/auth/LoginScreen.kt @@ -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)) + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/auth/LoginViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/auth/LoginViewModel.kt new file mode 100644 index 00000000..080152f3 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/auth/LoginViewModel.kt @@ -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 = _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." + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/auth/SignUpScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/auth/SignUpScreen.kt index 4757d769..91ca310d 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/auth/SignUpScreen.kt @@ -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)) + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/auth/SignUpViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/auth/SignUpViewModel.kt new file mode 100644 index 00000000..eea798d1 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/auth/SignUpViewModel.kt @@ -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 = _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) } + } + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/onboarding/CreateProfileScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/onboarding/CreateProfileScreen.kt index 7b0b9bf6..84da0445 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/onboarding/CreateProfileScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/onboarding/CreateProfileScreen.kt @@ -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)) + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/onboarding/CreateProfileViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/onboarding/CreateProfileViewModel.kt new file mode 100644 index 00000000..6e9488eb --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/onboarding/CreateProfileViewModel.kt @@ -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 = _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.") } + } + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingScreen.kt index 8da312a1..89fe536b 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingScreen.kt @@ -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) + ) + } + } + } + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..3f1e3f3f --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingViewModel.kt @@ -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 = _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) } +}