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:
null 2026-06-16 00:01:34 -05:00
parent af7603d61c
commit 8bcb3308c1
19 changed files with 1226 additions and 144 deletions

View File

@ -57,6 +57,7 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation // Navigation
implementation("androidx.navigation:navigation-compose:2.8.5") implementation("androidx.navigation:navigation-compose:2.8.5")

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,15 @@
package com.couplesconnect.app.di 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.SharedPreferencesLocalAnswerRepository
import com.couplesconnect.app.data.repository.RoomQuestionRepository import com.couplesconnect.app.data.repository.RoomQuestionRepository
import com.couplesconnect.app.data.repository.QuestionThreadRepositoryImpl 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.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository import com.couplesconnect.app.domain.repository.QuestionRepository
import com.couplesconnect.app.domain.repository.QuestionThreadRepository import com.couplesconnect.app.domain.repository.QuestionThreadRepository
import com.couplesconnect.app.domain.repository.UserRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -16,21 +20,18 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class RepositoryModule { abstract class RepositoryModule {
@Binds @Binds @Singleton
@Singleton abstract fun bindAuthRepository(impl: FirebaseAuthRepositoryImpl): AuthRepository
abstract fun bindQuestionThreadRepository(
impl: QuestionThreadRepositoryImpl
): QuestionThreadRepository
@Binds @Binds @Singleton
@Singleton abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
abstract fun bindQuestionRepository(
impl: RoomQuestionRepository
): QuestionRepository
@Binds @Binds @Singleton
@Singleton abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository
abstract fun bindLocalAnswerRepository(
impl: SharedPreferencesLocalAnswerRepository @Binds @Singleton
): LocalAnswerRepository abstract fun bindQuestionRepository(impl: RoomQuestionRepository): QuestionRepository
@Binds @Singleton
abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository
} }

View File

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

View File

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

View File

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

View File

@ -1,36 +1,145 @@
package com.couplesconnect.app.ui.auth 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.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.tooling.preview.Preview 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.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ForgotPasswordScreen( fun ForgotPasswordScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: ForgotPasswordViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Find your way back", val snackbar = remember { SnackbarHostState() }
section = "Auth", val focusManager = LocalFocusManager.current
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"
)
)
}
@Preview LaunchedEffect(state.error) {
@Composable state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
fun ForgotPasswordScreenPreview() { }
ForgotPasswordScreen()
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)
}
}
}
}
} }

View File

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

View File

@ -1,36 +1,176 @@
package com.couplesconnect.app.ui.auth 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.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.tooling.preview.Preview 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.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@Composable @Composable
fun LoginScreen( fun LoginScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: LoginViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Return to the room", val snackbar = remember { SnackbarHostState() }
section = "Auth", val focusManager = LocalFocusManager.current
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"
)
)
}
@Preview LaunchedEffect(state.success) {
@Composable if (state.success) onNavigate(AppRoute.HOME)
fun LoginScreenPreview() { }
LoginScreen() 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))
}
}
} }

View File

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

View File

@ -1,36 +1,177 @@
package com.couplesconnect.app.ui.auth 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.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.tooling.preview.Preview 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.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SignUpScreen( fun SignUpScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: SignUpViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Make a quiet account", val snackbar = remember { SnackbarHostState() }
section = "Auth", val focusManager = LocalFocusManager.current
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"
)
)
}
@Preview LaunchedEffect(state.success) {
@Composable if (state.success) onNavigate(AppRoute.CREATE_PROFILE)
fun SignUpScreenPreview() { }
SignUpScreen() 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))
}
}
} }

View File

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

View File

@ -1,36 +1,118 @@
package com.couplesconnect.app.ui.onboarding 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.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.tooling.preview.Preview 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.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@Composable @Composable
fun CreateProfileScreen( fun CreateProfileScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: CreateProfileViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Shape your presence", val snackbar = remember { SnackbarHostState() }
section = "Onboarding", val focusManager = LocalFocusManager.current
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"
)
)
}
@Preview LaunchedEffect(state.success) {
@Composable if (state.success) onNavigate(AppRoute.CREATE_INVITE)
fun CreateProfileScreenPreview() { }
CreateProfileScreen() 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))
}
}
} }

View File

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

View File

@ -1,36 +1,124 @@
package com.couplesconnect.app.ui.onboarding 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.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.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.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: OnboardingViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
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"
)
)
}
@Preview LaunchedEffect(state.navigateTo) {
@Composable state.navigateTo?.let { dest ->
fun OnboardingScreenPreview() { onNavigate(dest)
OnboardingScreen() 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)
)
}
}
}
}
}
} }

View File

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