diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index 56e867b..df0eb1d 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -28,6 +28,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "SENTRY_DSN", "\"${localProps.getProperty("SENTRY_DSN", "")}\"") + buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"${localProps.getProperty("GOOGLE_WEB_CLIENT_ID", "")}\"") } buildTypes { @@ -39,6 +40,23 @@ android { ) } } + + // Fail fast: GOOGLE_WEB_CLIENT_ID must be set in local.properties for release builds. + // (The resource default_web_client_id from google-services.json is preferred at runtime, + // but BuildConfig is the fallback and must not be empty in production.) + tasks.configureEach { + if (name.contains("Release", ignoreCase = true) && + (name.startsWith("assemble") || name.startsWith("bundle")) + ) { + doFirst { + val webClientId = localProps.getProperty("GOOGLE_WEB_CLIENT_ID", "") + check(webClientId.isNotEmpty()) { + "GOOGLE_WEB_CLIENT_ID must be set in local.properties for release builds. " + + "Add: GOOGLE_WEB_CLIENT_ID=" + } + } + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -61,10 +79,19 @@ dependencies { implementation(libs.sentry.android) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging.ktx) + implementation(libs.firebase.auth.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.services) + implementation(libs.googleid) + implementation(libs.datastore.preferences) + implementation(libs.coil.compose) implementation(libs.okhttp) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.okhttp.mockwebserver) diff --git a/mobile/app/src/androidTest/java/com/company/template/auth/LoginScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/auth/LoginScreenTest.kt new file mode 100644 index 0000000..5849035 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/company/template/auth/LoginScreenTest.kt @@ -0,0 +1,117 @@ +package com.company.template.auth + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.company.template.ui.theme.TemplateTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var capturedEmail = "" + private var capturedPassword = "" + + private fun setContent( + loginForm: LoginFormState = LoginFormState(), + uiState: AuthUiState = AuthUiState.Idle, + onEmailChange: (String) -> Unit = { capturedEmail = it }, + onPasswordChange: (String) -> Unit = { capturedPassword = it }, + onSignIn: () -> Unit = {}, + onSignInWithGoogle: () -> Unit = {}, + onNavigateToRegister: () -> Unit = {}, + ) { + composeTestRule.setContent { + TemplateTheme { + LoginScreen( + loginForm = loginForm, + uiState = uiState, + onEmailChange = onEmailChange, + onPasswordChange = onPasswordChange, + onSignIn = onSignIn, + onSignInWithGoogle = onSignInWithGoogle, + onNavigateToRegister = onNavigateToRegister, + ) + } + } + } + + @Test + fun loginScreen_displaysSignInHeading() { + setContent() + // The heading "Sign In" is displayed (there may also be a button with the same label) + composeTestRule.onNodeWithText("Sign In", useUnmergedTree = false).assertIsDisplayed() + } + + @Test + fun loginScreen_displaysGoogleSignInButton() { + setContent() + composeTestRule.onNodeWithText("Continue with Google").assertIsDisplayed() + } + + @Test + fun loginScreen_googleButton_invokesCallback() { + var called = false + setContent(onSignInWithGoogle = { called = true }) + composeTestRule.onNodeWithText("Continue with Google").performClick() + assertTrue(called) + } + + @Test + fun loginScreen_displaysErrorMessage() { + setContent(uiState = AuthUiState.Error("Invalid credentials")) + composeTestRule.onNodeWithText("Invalid credentials").assertIsDisplayed() + } + + @Test + fun loginScreen_displaysRegisterLink() { + setContent() + composeTestRule.onNodeWithText("Register").assertIsDisplayed() + } + + @Test + fun loginScreen_clickRegister_invokesCallback() { + var navigateCalled = false + setContent(onNavigateToRegister = { navigateCalled = true }) + composeTestRule.onNodeWithText("Register").performClick() + assertTrue(navigateCalled) + } + + @Test + fun loginScreen_clickSignIn_invokesCallback() { + var signInCalled = false + setContent( + loginForm = LoginFormState(email = "test@example.com", password = "secret"), + onSignIn = { signInCalled = true }, + ) + // Use the test tag to target the button specifically, not the heading + composeTestRule.onNodeWithTag(LoginTestTags.SIGN_IN_BUTTON).performClick() + assertTrue(signInCalled) + } + + @Test + fun loginScreen_typingEmail_updatesCallback() { + var updated = "" + setContent(onEmailChange = { updated = it }) + composeTestRule.onNodeWithText("Email").performTextInput("test@example.com") + assertTrue(updated.isNotEmpty()) + } + + @Test + fun loginScreen_typingPassword_updatesCallback() { + var updated = "" + setContent(onPasswordChange = { updated = it }) + composeTestRule.onNodeWithText("Password").performTextInput("secret123") + assertTrue(updated.isNotEmpty()) + } +} diff --git a/mobile/app/src/androidTest/java/com/company/template/auth/RegisterScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/auth/RegisterScreenTest.kt new file mode 100644 index 0000000..c7e44b5 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/company/template/auth/RegisterScreenTest.kt @@ -0,0 +1,96 @@ +package com.company.template.auth + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.company.template.ui.theme.TemplateTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RegisterScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun setContent( + registerForm: RegisterFormState = RegisterFormState(), + uiState: AuthUiState = AuthUiState.Idle, + onNameChange: (String) -> Unit = {}, + onEmailChange: (String) -> Unit = {}, + onPasswordChange: (String) -> Unit = {}, + onConfirmPasswordChange: (String) -> Unit = {}, + onRegister: () -> Unit = {}, + onNavigateToLogin: () -> Unit = {}, + ) { + composeTestRule.setContent { + TemplateTheme { + RegisterScreen( + registerForm = registerForm, + uiState = uiState, + onNameChange = onNameChange, + onEmailChange = onEmailChange, + onPasswordChange = onPasswordChange, + onConfirmPasswordChange = onConfirmPasswordChange, + onRegister = onRegister, + onNavigateToLogin = onNavigateToLogin, + ) + } + } + } + + @Test + fun registerScreen_displaysCreateAccountHeading() { + setContent() + // Heading text (there may also be a button with the same label; use useUnmergedTree if needed) + composeTestRule.onNodeWithText("Create Account", useUnmergedTree = false).assertIsDisplayed() + } + + @Test + fun registerScreen_displaysNameField() { + setContent() + composeTestRule.onNodeWithText("Full Name").assertIsDisplayed() + } + + @Test + fun registerScreen_displaysErrorMessage() { + setContent(uiState = AuthUiState.Error("Email already in use")) + composeTestRule.onNodeWithText("Email already in use").assertIsDisplayed() + } + + @Test + fun registerScreen_displaysSignInLink() { + setContent() + composeTestRule.onNodeWithText("Sign In").assertIsDisplayed() + } + + @Test + fun registerScreen_clickSignIn_invokesCallback() { + var navigateCalled = false + setContent(onNavigateToLogin = { navigateCalled = true }) + composeTestRule.onNodeWithText("Sign In").performClick() + assertTrue(navigateCalled) + } + + @Test + fun registerScreen_clickCreateAccount_invokesCallback() { + var registerCalled = false + setContent( + registerForm = RegisterFormState( + name = "Alice", + email = "alice@example.com", + password = "pass", + confirmPassword = "pass", + ), + onRegister = { registerCalled = true }, + ) + // Use the test tag to target the button specifically, not the heading + composeTestRule.onNodeWithTag(RegisterTestTags.CREATE_ACCOUNT_BUTTON).performClick() + assertTrue(registerCalled) + } +} diff --git a/mobile/app/src/androidTest/java/com/company/template/home/HomeScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/home/HomeScreenTest.kt new file mode 100644 index 0000000..0d897c4 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/company/template/home/HomeScreenTest.kt @@ -0,0 +1,74 @@ +package com.company.template.home + +import androidx.compose.ui.test.assertDoesNotExist +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.company.template.ui.theme.TemplateTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun homeScreen_displaysWelcomeHeading() { + composeTestRule.setContent { + TemplateTheme { + HomeScreen(displayName = "Alice", onSignOut = {}) + } + } + composeTestRule.onNodeWithText("Welcome back!").assertIsDisplayed() + } + + @Test + fun homeScreen_displaysDisplayName() { + composeTestRule.setContent { + TemplateTheme { + HomeScreen(displayName = "Alice", onSignOut = {}) + } + } + composeTestRule.onNodeWithText("Alice").assertIsDisplayed() + } + + @Test + fun homeScreen_displaysSignOutButton() { + composeTestRule.setContent { + TemplateTheme { + HomeScreen(displayName = "Alice", onSignOut = {}) + } + } + composeTestRule.onNodeWithText("Sign Out").assertIsDisplayed() + } + + @Test + fun homeScreen_clickSignOut_invokesCallback() { + var signOutCalled = false + composeTestRule.setContent { + TemplateTheme { + HomeScreen(displayName = "Alice", onSignOut = { signOutCalled = true }) + } + } + composeTestRule.onNodeWithText("Sign Out").performClick() + assertTrue(signOutCalled) + } + + @Test + fun homeScreen_emptyDisplayName_doesNotShowNameText() { + composeTestRule.setContent { + TemplateTheme { + HomeScreen(displayName = "", onSignOut = {}) + } + } + composeTestRule.onNodeWithText("Welcome back!").assertIsDisplayed() + // An empty string should not produce a visible name node + composeTestRule.onNodeWithText("").assertDoesNotExist() + } +} diff --git a/mobile/app/src/androidTest/java/com/company/template/onboarding/OnboardingScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/onboarding/OnboardingScreenTest.kt new file mode 100644 index 0000000..e4fada6 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/company/template/onboarding/OnboardingScreenTest.kt @@ -0,0 +1,51 @@ +package com.company.template.onboarding + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.company.template.ui.theme.TemplateTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OnboardingScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun onboardingScreen_displaysWelcomeHeading() { + composeTestRule.setContent { + TemplateTheme { + OnboardingScreen(onGetStarted = {}) + } + } + composeTestRule.onNodeWithText("Welcome").assertIsDisplayed() + } + + @Test + fun onboardingScreen_displaysGetStartedButton() { + composeTestRule.setContent { + TemplateTheme { + OnboardingScreen(onGetStarted = {}) + } + } + composeTestRule.onNodeWithText("Get Started").assertIsDisplayed() + } + + @Test + fun onboardingScreen_clickGetStarted_invokesCallback() { + var callbackInvoked = false + composeTestRule.setContent { + TemplateTheme { + OnboardingScreen(onGetStarted = { callbackInvoked = true }) + } + } + composeTestRule.onNodeWithText("Get Started").performClick() + assertTrue(callbackInvoked) + } +} diff --git a/mobile/app/src/main/java/com/company/template/MainActivity.kt b/mobile/app/src/main/java/com/company/template/MainActivity.kt index 4b18458..7da30f7 100644 --- a/mobile/app/src/main/java/com/company/template/MainActivity.kt +++ b/mobile/app/src/main/java/com/company/template/MainActivity.kt @@ -9,13 +9,18 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import com.company.template.auth.AuthViewModel +import com.company.template.auth.FirebaseAuthRepository +import com.company.template.navigation.AppNavGraph +import com.company.template.navigation.AppViewModel +import com.company.template.onboarding.DataStoreOnboardingRepository import com.company.template.ui.theme.TemplateTheme import com.google.firebase.messaging.FirebaseMessaging import io.sentry.android.core.SentryAndroid @@ -34,6 +39,16 @@ class MainActivity : ComponentActivity() { private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ } + private val authRepository by lazy { FirebaseAuthRepository(applicationContext) } + private val onboardingRepository by lazy { DataStoreOnboardingRepository(applicationContext) } + + private val authViewModel: AuthViewModel by viewModels { + AuthViewModel.factory(authRepository) + } + private val appViewModel: AppViewModel by viewModels { + AppViewModel.factory(authRepository, onboardingRepository) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -52,8 +67,9 @@ class MainActivity : ComponentActivity() { setContent { TemplateTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", + AppNavGraph( + appViewModel = appViewModel, + authViewModel = authViewModel, modifier = Modifier.padding(innerPadding) ) } @@ -62,18 +78,18 @@ class MainActivity : ComponentActivity() { } } +@Preview(showBackground = true) +@Composable +fun AppPreview() { + TemplateTheme { + Greeting("Android") + } +} + @Composable fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( + androidx.compose.material3.Text( text = "Hello $name!", modifier = modifier ) } - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - TemplateTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/company/template/auth/AuthRepository.kt b/mobile/app/src/main/java/com/company/template/auth/AuthRepository.kt new file mode 100644 index 0000000..31b9ae5 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/auth/AuthRepository.kt @@ -0,0 +1,11 @@ +package com.company.template.auth + +import kotlinx.coroutines.flow.StateFlow + +interface AuthRepository { + val authStateFlow: StateFlow + suspend fun signInWithEmail(email: String, password: String): Result + suspend fun registerWithEmail(name: String, email: String, password: String): Result + suspend fun signInWithGoogle(googleIdToken: String): Result + suspend fun signOut() +} diff --git a/mobile/app/src/main/java/com/company/template/auth/AuthViewModel.kt b/mobile/app/src/main/java/com/company/template/auth/AuthViewModel.kt new file mode 100644 index 0000000..80eb6ac --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/auth/AuthViewModel.kt @@ -0,0 +1,184 @@ +package com.company.template.auth + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.NoCredentialException +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.company.template.BuildConfig +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed class AuthUiState { + data object Idle : AuthUiState() + data object Loading : AuthUiState() + data object Success : AuthUiState() + data class Error(val message: String) : AuthUiState() +} + +data class LoginFormState( + val email: String = "", + val password: String = "", +) + +data class RegisterFormState( + val name: String = "", + val email: String = "", + val password: String = "", + val confirmPassword: String = "", +) + +class AuthViewModel(private val repo: AuthRepository) : ViewModel() { + + private val _uiState = MutableStateFlow(AuthUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + val currentUser: StateFlow = repo.authStateFlow + + private val _loginForm = MutableStateFlow(LoginFormState()) + val loginForm: StateFlow = _loginForm.asStateFlow() + + private val _registerForm = MutableStateFlow(RegisterFormState()) + val registerForm: StateFlow = _registerForm.asStateFlow() + + // Login form updates — clear error on any field change + fun updateLoginEmail(email: String) { + _loginForm.update { it.copy(email = email) } + clearError() + } + + fun updateLoginPassword(password: String) { + _loginForm.update { it.copy(password = password) } + clearError() + } + + // Register form updates + fun updateRegisterName(name: String) { + _registerForm.update { it.copy(name = name) } + clearError() + } + + fun updateRegisterEmail(email: String) { + _registerForm.update { it.copy(email = email) } + clearError() + } + + fun updateRegisterPassword(password: String) { + _registerForm.update { it.copy(password = password) } + clearError() + } + + fun updateRegisterConfirmPassword(confirmPassword: String) { + _registerForm.update { it.copy(confirmPassword = confirmPassword) } + clearError() + } + + fun signIn() { + val (email, password) = _loginForm.value + viewModelScope.launch { + _uiState.value = AuthUiState.Loading + repo.signInWithEmail(email, password) + .onSuccess { _uiState.value = AuthUiState.Success } + .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Sign in failed") } + } + } + + fun register() { + val (name, email, password, confirm) = _registerForm.value + if (password != confirm) { + _uiState.value = AuthUiState.Error("Passwords do not match") + return + } + viewModelScope.launch { + _uiState.value = AuthUiState.Loading + repo.registerWithEmail(name, email, password) + .onSuccess { _uiState.value = AuthUiState.Success } + .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Registration failed") } + } + } + + fun signInWithGoogle(activity: Activity) { + viewModelScope.launch { + _uiState.value = AuthUiState.Loading + fetchGoogleIdToken(activity) + .onSuccess { token -> + repo.signInWithGoogle(token) + .onSuccess { _uiState.value = AuthUiState.Success } + .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Sign in failed") } + } + .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Google sign in failed") } + } + } + + fun signOut() { + viewModelScope.launch { + repo.signOut() + _uiState.value = AuthUiState.Idle + } + } + + fun clearError() { + if (_uiState.value is AuthUiState.Error) _uiState.value = AuthUiState.Idle + } + + private suspend fun fetchGoogleIdToken(activity: Activity): Result = runCatching { + val webClientId = resolveWebClientId(activity) + check(webClientId.isNotEmpty()) { + "Google Sign-In not configured. Enable Google Sign-In in Firebase Console and " + + "re-download google-services.json, or add GOOGLE_WEB_CLIENT_ID to local.properties." + } + + val credentialManager = CredentialManager.create(activity) + val option = GetSignInWithGoogleOption.Builder(webClientId).build() + val request = GetCredentialRequest.Builder().addCredentialOption(option).build() + + val result = try { + credentialManager.getCredential(activity, request) + } catch (e: NoCredentialException) { + activity.startActivity( + Intent(Settings.ACTION_ADD_ACCOUNT).apply { + putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) + } + ) + error("No Google account found on device. Please add an account and try again.") + } + + val credential = result.credential + check( + credential is CustomCredential && + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) { "Unexpected credential type: ${credential.type}" } + + GoogleIdTokenCredential.createFrom(credential.data).idToken + } + + private fun resolveWebClientId(context: Context): String { + val resId = context.resources.getIdentifier( + "default_web_client_id", "string", context.packageName + ) + if (resId != 0) { + val fromResource = context.getString(resId) + if (fromResource.isNotEmpty()) return fromResource + } + return BuildConfig.GOOGLE_WEB_CLIENT_ID + } + + companion object { + fun factory(repo: AuthRepository): ViewModelProvider.Factory = viewModelFactory { + initializer { AuthViewModel(repo) } + } + } +} diff --git a/mobile/app/src/main/java/com/company/template/auth/FirebaseAuthRepository.kt b/mobile/app/src/main/java/com/company/template/auth/FirebaseAuthRepository.kt new file mode 100644 index 0000000..f3a7e05 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/auth/FirebaseAuthRepository.kt @@ -0,0 +1,77 @@ +package com.company.template.auth + +import android.content.Context +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.CredentialManager +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.UserProfileChangeRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.tasks.await + +class FirebaseAuthRepository( + private val context: Context, + private val auth: FirebaseAuth = FirebaseAuth.getInstance(), +) : AuthRepository { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + override val authStateFlow: StateFlow = callbackFlow { + val listener = FirebaseAuth.AuthStateListener { trySend(it.currentUser) } + auth.addAuthStateListener(listener) + awaitClose { auth.removeAuthStateListener(listener) } + } + .map { it?.toDomain() } + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = auth.currentUser?.toDomain(), + ) + + override suspend fun signInWithEmail(email: String, password: String): Result = + runCatching { + auth.signInWithEmailAndPassword(email, password).await() + Unit + } + + override suspend fun registerWithEmail( + name: String, + email: String, + password: String, + ): Result = runCatching { + val result = auth.createUserWithEmailAndPassword(email, password).await() + result.user?.updateProfile( + UserProfileChangeRequest.Builder().setDisplayName(name).build() + )?.await() + Unit + } + + override suspend fun signInWithGoogle(googleIdToken: String): Result = runCatching { + val credential = GoogleAuthProvider.getCredential(googleIdToken, null) + auth.signInWithCredential(credential).await() + Unit + } + + override suspend fun signOut() { + auth.signOut() + // Clear saved Google credential so the next sign-in shows the account picker + CredentialManager.create(context) + .clearCredentialState(ClearCredentialStateRequest()) + } + + private fun FirebaseUser.toDomain() = User( + uid = uid, + email = email, + displayName = displayName, + photoUrl = photoUrl?.toString(), + ) +} diff --git a/mobile/app/src/main/java/com/company/template/auth/LoginScreen.kt b/mobile/app/src/main/java/com/company/template/auth/LoginScreen.kt new file mode 100644 index 0000000..2f6fc18 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/auth/LoginScreen.kt @@ -0,0 +1,203 @@ +package com.company.template.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.company.template.ui.theme.TemplateTheme + +internal object LoginTestTags { + const val SIGN_IN_BUTTON = "login_sign_in_button" +} + +private val GoogleIcon: ImageVector + get() = ImageVector.Builder( + name = "GoogleIcon", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path(fill = SolidColor(Color(0xFF4285F4))) { + moveTo(22.56f, 12.25f); curveToRelative(0f, -0.78f, -0.07f, -1.53f, -0.2f, -2.25f) + horizontalLineTo(12f); verticalLineToRelative(4.26f); horizontalLineToRelative(5.92f) + curveToRelative(-0.26f, 1.37f, -1.04f, 2.53f, -2.21f, 3.31f); verticalLineToRelative(2.77f) + horizontalLineToRelative(3.57f); curveToRelative(2.08f, -1.92f, 3.28f, -4.74f, 3.28f, -8.09f); close() + } + path(fill = SolidColor(Color(0xFF34A853))) { + moveTo(12f, 23f); curveToRelative(2.97f, 0f, 5.46f, -0.98f, 7.28f, -2.66f) + lineToRelative(-3.57f, -2.77f); curveToRelative(-0.98f, 0.66f, -2.23f, 1.06f, -3.71f, 1.06f) + curveToRelative(-2.86f, 0f, -5.29f, -1.93f, -6.16f, -4.53f); horizontalLineTo(2.18f) + verticalLineToRelative(2.84f); curveTo(3.99f, 20.53f, 7.7f, 23f, 12f, 23f); close() + } + path(fill = SolidColor(Color(0xFFFBBC05))) { + moveTo(5.84f, 14.09f); curveToRelative(-0.22f, -0.66f, -0.35f, -1.36f, -0.35f, -2.09f) + reflectiveCurveToRelative(0.13f, -1.43f, 0.35f, -2.09f); verticalLineTo(7.07f); horizontalLineTo(2.18f) + curveTo(1.43f, 8.55f, 1f, 10.22f, 1f, 12f); reflectiveCurveToRelative(0.43f, 3.45f, 1.18f, 4.93f) + lineToRelative(2.85f, -2.22f); lineToRelative(0.81f, -0.62f); close() + } + path(fill = SolidColor(Color(0xFFEA4335))) { + moveTo(12f, 5.38f); curveToRelative(1.62f, 0f, 3.06f, 0.56f, 4.21f, 1.64f) + lineToRelative(3.15f, -3.15f); curveTo(17.45f, 2.09f, 14.97f, 1f, 12f, 1f) + curveTo(7.7f, 1f, 3.99f, 3.47f, 2.18f, 7.07f); lineToRelative(3.66f, 2.84f) + curveToRelative(0.87f, -2.6f, 3.3f, -4.53f, 6.16f, -4.53f); close() + } + }.build() + +@Composable +fun LoginScreen( + loginForm: LoginFormState, + uiState: AuthUiState, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSignIn: () -> Unit, + onSignInWithGoogle: () -> Unit, + onNavigateToRegister: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLoading = uiState is AuthUiState.Loading + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Sign In", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(32.dp)) + OutlinedButton( + onClick = onSignInWithGoogle, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = GoogleIcon, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(text = "Continue with Google") + } + Spacer(modifier = Modifier.height(20.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text(text = " or ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(20.dp)) + OutlinedTextField( + value = loginForm.email, + onValueChange = onEmailChange, + label = { Text("Email") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = loginForm.password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + if (uiState is AuthUiState.Error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + if (isLoading) { + CircularProgressIndicator() + } else { + Button( + onClick = onSignIn, + enabled = loginForm.email.isNotBlank() && loginForm.password.isNotBlank(), + modifier = Modifier + .fillMaxWidth() + .testTag(LoginTestTags.SIGN_IN_BUTTON), + ) { + Text(text = "Sign In") + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text( + text = "Don't have an account?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + TextButton(onClick = onNavigateToRegister) { Text(text = "Register") } + } + } +} + +@Preview(showBackground = true) +@Composable +fun LoginScreenPreview() { + TemplateTheme { + LoginScreen( + loginForm = LoginFormState(), + uiState = AuthUiState.Idle, + onEmailChange = {}, + onPasswordChange = {}, + onSignIn = {}, + onSignInWithGoogle = {}, + onNavigateToRegister = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun LoginScreenErrorPreview() { + TemplateTheme { + LoginScreen( + loginForm = LoginFormState(email = "a@b.com", password = "wrong"), + uiState = AuthUiState.Error("Invalid credentials"), + onEmailChange = {}, + onPasswordChange = {}, + onSignIn = {}, + onSignInWithGoogle = {}, + onNavigateToRegister = {}, + ) + } +} diff --git a/mobile/app/src/main/java/com/company/template/auth/RegisterScreen.kt b/mobile/app/src/main/java/com/company/template/auth/RegisterScreen.kt new file mode 100644 index 0000000..cfd5810 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/auth/RegisterScreen.kt @@ -0,0 +1,151 @@ +package com.company.template.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.company.template.ui.theme.TemplateTheme + +internal object RegisterTestTags { + const val CREATE_ACCOUNT_BUTTON = "register_create_account_button" +} + +@Composable +fun RegisterScreen( + registerForm: RegisterFormState, + uiState: AuthUiState, + onNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onConfirmPasswordChange: (String) -> Unit, + onRegister: () -> Unit, + onNavigateToLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 48.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Create Account", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(32.dp)) + OutlinedTextField( + value = registerForm.name, + onValueChange = onNameChange, + label = { Text("Full Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = registerForm.email, + onValueChange = onEmailChange, + label = { Text("Email") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = registerForm.password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = registerForm.confirmPassword, + onValueChange = onConfirmPasswordChange, + label = { Text("Confirm Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + if (uiState is AuthUiState.Error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + if (uiState is AuthUiState.Loading) { + CircularProgressIndicator() + } else { + Button( + onClick = onRegister, + enabled = registerForm.name.isNotBlank() && registerForm.email.isNotBlank() && + registerForm.password.isNotBlank() && registerForm.confirmPassword.isNotBlank(), + modifier = Modifier + .fillMaxWidth() + .testTag(RegisterTestTags.CREATE_ACCOUNT_BUTTON), + ) { + Text(text = "Create Account") + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Already have an account?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + TextButton(onClick = onNavigateToLogin) { + Text(text = "Sign In") + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun RegisterScreenPreview() { + TemplateTheme { + RegisterScreen( + registerForm = RegisterFormState(), + uiState = AuthUiState.Idle, + onNameChange = {}, + onEmailChange = {}, + onPasswordChange = {}, + onConfirmPasswordChange = {}, + onRegister = {}, + onNavigateToLogin = {}, + ) + } +} diff --git a/mobile/app/src/main/java/com/company/template/auth/User.kt b/mobile/app/src/main/java/com/company/template/auth/User.kt new file mode 100644 index 0000000..f692728 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/auth/User.kt @@ -0,0 +1,8 @@ +package com.company.template.auth + +data class User( + val uid: String, + val email: String?, + val displayName: String?, + val photoUrl: String?, +) diff --git a/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt new file mode 100644 index 0000000..7bb6967 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt @@ -0,0 +1,81 @@ +package com.company.template.home + +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.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.company.template.ui.theme.TemplateTheme + +@Composable +fun HomeScreen( + displayName: String, + onSignOut: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Welcome back!", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + if (displayName.isNotBlank()) { + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(48.dp)) + } + Button( + onClick = onSignOut, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Sign Out") + } + } +} + +@Preview(showBackground = true) +@Composable +fun HomeScreenPreview() { + TemplateTheme { + HomeScreen( + displayName = "Alice", + onSignOut = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun HomeScreenNoNamePreview() { + TemplateTheme { + HomeScreen( + displayName = "", + onSignOut = {} + ) + } +} diff --git a/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt b/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt new file mode 100644 index 0000000..b352a7f --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt @@ -0,0 +1,102 @@ +package com.company.template.navigation + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.company.template.auth.AuthUiState +import com.company.template.auth.AuthViewModel +import com.company.template.auth.User +import com.company.template.auth.LoginScreen +import com.company.template.auth.RegisterScreen +import com.company.template.home.HomeScreen +import com.company.template.onboarding.OnboardingScreen + +private const val ROUTE_ONBOARDING = "onboarding" +private const val ROUTE_LOGIN = "login" +private const val ROUTE_REGISTER = "register" +private const val ROUTE_HOME = "home" + +@Composable +fun AppNavGraph( + appViewModel: AppViewModel, + authViewModel: AuthViewModel, + navController: NavHostController = rememberNavController(), + modifier: Modifier = Modifier, +) { + val startDestination by appViewModel.startDestination.collectAsStateWithLifecycle() + val authUiState by authViewModel.uiState.collectAsStateWithLifecycle() + val currentUser: User? by authViewModel.currentUser.collectAsStateWithLifecycle() + val loginForm by authViewModel.loginForm.collectAsStateWithLifecycle() + val registerForm by authViewModel.registerForm.collectAsStateWithLifecycle() + val activity = LocalContext.current as Activity + + // Navigate away from auth screens when the user successfully signs in + LaunchedEffect(authUiState) { + if (authUiState is AuthUiState.Success) { + navController.navigate(ROUTE_HOME) { + popUpTo(0) { inclusive = true } + } + } + } + + val resolvedStart = when (startDestination) { + StartDestination.ONBOARDING -> ROUTE_ONBOARDING + StartDestination.LOGIN -> ROUTE_LOGIN + StartDestination.HOME -> ROUTE_HOME + null -> return // wait until resolved + } + + NavHost( + navController = navController, + startDestination = resolvedStart, + modifier = modifier, + ) { + composable(ROUTE_ONBOARDING) { + OnboardingScreen( + onGetStarted = { + appViewModel.markOnboardingSeen() + navController.navigate(ROUTE_LOGIN) { + popUpTo(ROUTE_ONBOARDING) { inclusive = true } + } + } + ) + } + composable(ROUTE_LOGIN) { + LoginScreen( + loginForm = loginForm, + uiState = authUiState, + onEmailChange = authViewModel::updateLoginEmail, + onPasswordChange = authViewModel::updateLoginPassword, + onSignIn = authViewModel::signIn, + onSignInWithGoogle = { authViewModel.signInWithGoogle(activity) }, + onNavigateToRegister = { navController.navigate(ROUTE_REGISTER) }, + ) + } + composable(ROUTE_REGISTER) { + RegisterScreen( + registerForm = registerForm, + uiState = authUiState, + onNameChange = authViewModel::updateRegisterName, + onEmailChange = authViewModel::updateRegisterEmail, + onPasswordChange = authViewModel::updateRegisterPassword, + onConfirmPasswordChange = authViewModel::updateRegisterConfirmPassword, + onRegister = authViewModel::register, + onNavigateToLogin = { navController.popBackStack() }, + ) + } + composable(ROUTE_HOME) { + HomeScreen( + displayName = currentUser?.displayName ?: currentUser?.email ?: "", + onSignOut = { authViewModel.signOut() }, + ) + } + } +} diff --git a/mobile/app/src/main/java/com/company/template/navigation/AppViewModel.kt b/mobile/app/src/main/java/com/company/template/navigation/AppViewModel.kt new file mode 100644 index 0000000..63dd9f8 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/navigation/AppViewModel.kt @@ -0,0 +1,57 @@ +package com.company.template.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.company.template.auth.AuthRepository +import com.company.template.onboarding.OnboardingRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +enum class StartDestination { + ONBOARDING, + LOGIN, + HOME +} + +class AppViewModel( + authRepository: AuthRepository, + private val onboardingRepository: OnboardingRepository, +) : ViewModel() { + + val startDestination: StateFlow = + combine( + onboardingRepository.hasSeenOnboarding(), + authRepository.authStateFlow, + ) { hasSeen, user -> + when { + user != null -> StartDestination.HOME + !hasSeen -> StartDestination.ONBOARDING + else -> StartDestination.LOGIN + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null, + ) + + fun markOnboardingSeen() { + viewModelScope.launch { + runCatching { onboardingRepository.markSeen() } + } + } + + companion object { + fun factory( + authRepository: AuthRepository, + onboardingRepository: OnboardingRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { AppViewModel(authRepository, onboardingRepository) } + } + } +} diff --git a/mobile/app/src/main/java/com/company/template/onboarding/DataStoreOnboardingRepository.kt b/mobile/app/src/main/java/com/company/template/onboarding/DataStoreOnboardingRepository.kt new file mode 100644 index 0000000..e498a09 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/onboarding/DataStoreOnboardingRepository.kt @@ -0,0 +1,31 @@ +package com.company.template.onboarding + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +private val Context.dataStore: DataStore by preferencesDataStore(name = "onboarding_prefs") + +class DataStoreOnboardingRepository(private val context: Context) : OnboardingRepository { + + private val hasSeenKey = booleanPreferencesKey("has_seen_onboarding") + + override fun hasSeenOnboarding(): Flow = + context.dataStore.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + } + .map { prefs -> prefs[hasSeenKey] ?: false } + + override suspend fun markSeen() { + context.dataStore.edit { prefs -> prefs[hasSeenKey] = true } + } +} diff --git a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingRepository.kt b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingRepository.kt new file mode 100644 index 0000000..fe5c0e2 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingRepository.kt @@ -0,0 +1,8 @@ +package com.company.template.onboarding + +import kotlinx.coroutines.flow.Flow + +interface OnboardingRepository { + fun hasSeenOnboarding(): Flow + suspend fun markSeen() +} diff --git a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..576529b --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt @@ -0,0 +1,62 @@ +package com.company.template.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.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.company.template.ui.theme.TemplateTheme + +@Composable +fun OnboardingScreen( + onGetStarted: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Welcome", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Get started with your account to access all features.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(48.dp)) + Button( + onClick = onGetStarted, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Get Started") + } + } +} + +@Preview(showBackground = true) +@Composable +fun OnboardingScreenPreview() { + TemplateTheme { + OnboardingScreen(onGetStarted = {}) + } +} diff --git a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingViewModel.kt b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..8d48fc0 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingViewModel.kt @@ -0,0 +1,28 @@ +package com.company.template.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +class OnboardingViewModel(private val repo: OnboardingRepository) : ViewModel() { + + fun hasSeenOnboarding(): Flow = repo.hasSeenOnboarding() + + fun markSeen(onComplete: () -> Unit = {}, onError: (Throwable) -> Unit = {}) { + viewModelScope.launch { + runCatching { repo.markSeen() } + .onSuccess { onComplete() } + .onFailure { onError(it) } + } + } + + companion object { + fun factory(repo: OnboardingRepository): ViewModelProvider.Factory = viewModelFactory { + initializer { OnboardingViewModel(repo) } + } + } +} diff --git a/mobile/app/src/test/java/com/company/template/auth/AuthViewModelTest.kt b/mobile/app/src/test/java/com/company/template/auth/AuthViewModelTest.kt new file mode 100644 index 0000000..6511a84 --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/auth/AuthViewModelTest.kt @@ -0,0 +1,183 @@ +package com.company.template.auth + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +// --------------- Fake repository (no Mockito) --------------- + +class FakeAuthRepository : AuthRepository { + private val _authStateFlow = MutableStateFlow(null) + override val authStateFlow: StateFlow = _authStateFlow + + var signInResult: Result = Result.success(Unit) + var registerResult: Result = Result.success(Unit) + var googleSignInResult: Result = Result.success(Unit) + var signOutCalled = false + + fun setUser(user: User?) { _authStateFlow.value = user } + + override suspend fun signInWithEmail(email: String, password: String): Result = + signInResult + + override suspend fun registerWithEmail(name: String, email: String, password: String): Result = + registerResult + + override suspend fun signInWithGoogle(googleIdToken: String): Result = googleSignInResult + + override suspend fun signOut() { + signOutCalled = true + _authStateFlow.value = null + } +} + +// --------------- Tests --------------- + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthViewModelTest { + + private lateinit var fakeRepo: FakeAuthRepository + private lateinit var viewModel: AuthViewModel + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + fakeRepo = FakeAuthRepository() + viewModel = AuthViewModel(fakeRepo) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state is Idle`() { + assertEquals(AuthUiState.Idle, viewModel.uiState.value) + } + + @Test + fun `signIn emits Success on repository success`() = runTest { + fakeRepo.signInResult = Result.success(Unit) + viewModel.updateLoginEmail("test@example.com") + viewModel.updateLoginPassword("password") + viewModel.signIn() + assertEquals(AuthUiState.Success, viewModel.uiState.value) + } + + @Test + fun `signIn emits Error on repository failure`() = runTest { + fakeRepo.signInResult = Result.failure(Exception("Invalid credentials")) + viewModel.updateLoginEmail("test@example.com") + viewModel.updateLoginPassword("wrong") + viewModel.signIn() + val state = viewModel.uiState.value + assertTrue(state is AuthUiState.Error) + assertEquals("Invalid credentials", (state as AuthUiState.Error).message) + } + + @Test + fun `register emits Success on repository success`() = runTest { + fakeRepo.registerResult = Result.success(Unit) + viewModel.updateRegisterName("Alice") + viewModel.updateRegisterEmail("alice@example.com") + viewModel.updateRegisterPassword("password1") + viewModel.updateRegisterConfirmPassword("password1") + viewModel.register() + assertEquals(AuthUiState.Success, viewModel.uiState.value) + } + + @Test + fun `register emits Error when passwords do not match`() = runTest { + viewModel.updateRegisterName("Alice") + viewModel.updateRegisterEmail("alice@example.com") + viewModel.updateRegisterPassword("password1") + viewModel.updateRegisterConfirmPassword("password2") + viewModel.register() + val state = viewModel.uiState.value + assertTrue(state is AuthUiState.Error) + assertEquals("Passwords do not match", (state as AuthUiState.Error).message) + } + + @Test + fun `register emits Error on repository failure`() = runTest { + fakeRepo.registerResult = Result.failure(Exception("Email already in use")) + viewModel.updateRegisterName("Alice") + viewModel.updateRegisterEmail("alice@example.com") + viewModel.updateRegisterPassword("password1") + viewModel.updateRegisterConfirmPassword("password1") + viewModel.register() + val state = viewModel.uiState.value + assertTrue(state is AuthUiState.Error) + assertEquals("Email already in use", (state as AuthUiState.Error).message) + } + + @Test + fun `signOut calls repository signOut and resets uiState to Idle`() = runTest { + fakeRepo.signInResult = Result.success(Unit) + viewModel.updateLoginEmail("test@example.com") + viewModel.updateLoginPassword("pw") + viewModel.signIn() + assertEquals(AuthUiState.Success, viewModel.uiState.value) + + viewModel.signOut() + assertTrue(fakeRepo.signOutCalled) + assertEquals(AuthUiState.Idle, viewModel.uiState.value) + } + + @Test + fun `clearError resets Error state to Idle`() = runTest { + fakeRepo.signInResult = Result.failure(Exception("Bad")) + viewModel.updateLoginEmail("x@y.com") + viewModel.updateLoginPassword("pw") + viewModel.signIn() + assertTrue(viewModel.uiState.value is AuthUiState.Error) + + viewModel.clearError() + assertEquals(AuthUiState.Idle, viewModel.uiState.value) + } + + @Test + fun `updating login field clears existing error`() = runTest { + fakeRepo.signInResult = Result.failure(Exception("Bad")) + viewModel.updateLoginEmail("x@y.com") + viewModel.updateLoginPassword("pw") + viewModel.signIn() + assertTrue(viewModel.uiState.value is AuthUiState.Error) + + viewModel.updateLoginEmail("new@example.com") + assertEquals(AuthUiState.Idle, viewModel.uiState.value) + } + + @Test + fun `loginForm reflects field updates`() { + viewModel.updateLoginEmail("a@b.com") + viewModel.updateLoginPassword("secret") + val form = viewModel.loginForm.value + assertEquals("a@b.com", form.email) + assertEquals("secret", form.password) + } + + @Test + fun `registerForm reflects field updates`() { + viewModel.updateRegisterName("Bob") + viewModel.updateRegisterEmail("bob@example.com") + viewModel.updateRegisterPassword("pass") + viewModel.updateRegisterConfirmPassword("pass") + val form = viewModel.registerForm.value + assertEquals("Bob", form.name) + assertEquals("bob@example.com", form.email) + assertEquals("pass", form.password) + assertEquals("pass", form.confirmPassword) + } +} diff --git a/mobile/app/src/test/java/com/company/template/navigation/AppViewModelTest.kt b/mobile/app/src/test/java/com/company/template/navigation/AppViewModelTest.kt new file mode 100644 index 0000000..e245974 --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/navigation/AppViewModelTest.kt @@ -0,0 +1,152 @@ +package com.company.template.navigation + +import com.company.template.auth.AuthRepository +import com.company.template.auth.User +import com.company.template.onboarding.OnboardingRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +// --------------- Fakes --------------- + +class FakeAuthRepositoryForNav : AuthRepository { + private val _authStateFlow = MutableStateFlow(null) + override val authStateFlow: StateFlow = _authStateFlow + + fun setUser(user: User?) { + _authStateFlow.value = user + } + + override suspend fun signInWithEmail(email: String, password: String): Result = + Result.success(Unit) + + override suspend fun registerWithEmail(name: String, email: String, password: String): Result = + Result.success(Unit) + + override suspend fun signInWithGoogle(googleIdToken: String): Result = Result.success(Unit) + + override suspend fun signOut() { + _authStateFlow.value = null + } +} + +class FakeOnboardingRepositoryForNav : OnboardingRepository { + private val _seen = MutableStateFlow(false) + + fun setSeen(seen: Boolean) { + _seen.value = seen + } + + override fun hasSeenOnboarding(): Flow = _seen + + override suspend fun markSeen() { + _seen.value = true + } +} + +// --------------- Tests --------------- + +@OptIn(ExperimentalCoroutinesApi::class) +class AppViewModelTest { + + private lateinit var fakeAuth: FakeAuthRepositoryForNav + private lateinit var fakeOnboarding: FakeOnboardingRepositoryForNav + private lateinit var viewModel: AppViewModel + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + fakeAuth = FakeAuthRepositoryForNav() + fakeOnboarding = FakeOnboardingRepositoryForNav() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() { + viewModel = AppViewModel(fakeAuth, fakeOnboarding) + } + + @Test + fun `startDestination is Onboarding when not seen and not signed in`() = runTest { + fakeOnboarding.setSeen(false) + fakeAuth.setUser(null) + createViewModel() + + val dest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.ONBOARDING, dest) + } + + @Test + fun `startDestination is Login when onboarding seen and not signed in`() = runTest { + fakeOnboarding.setSeen(true) + fakeAuth.setUser(null) + createViewModel() + + val dest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.LOGIN, dest) + } + + @Test + fun `startDestination is Home when user is signed in`() = runTest { + fakeOnboarding.setSeen(true) + fakeAuth.setUser(User(uid = "uid123", email = "a@b.com", displayName = "Alice", photoUrl = null)) + createViewModel() + + val dest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.HOME, dest) + } + + @Test + fun `startDestination transitions to Login after onboarding is marked seen`() = runTest { + fakeOnboarding.setSeen(false) + fakeAuth.setUser(null) + createViewModel() + + val firstDest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.ONBOARDING, firstDest) + + fakeOnboarding.setSeen(true) + val secondDest = viewModel.startDestination.first { it == StartDestination.LOGIN } + assertEquals(StartDestination.LOGIN, secondDest) + } + + @Test + fun `startDestination transitions to Login after sign out`() = runTest { + fakeOnboarding.setSeen(true) + fakeAuth.setUser(User(uid = "uid123", email = "a@b.com", displayName = "Alice", photoUrl = null)) + createViewModel() + + val homeDest = viewModel.startDestination.first { it == StartDestination.HOME } + assertEquals(StartDestination.HOME, homeDest) + + fakeAuth.signOut() + val loginDest = viewModel.startDestination.first { it == StartDestination.LOGIN } + assertEquals(StartDestination.LOGIN, loginDest) + } + + @Test + fun `markOnboardingSeen persists the flag`() = runTest { + fakeOnboarding.setSeen(false) + fakeAuth.setUser(null) + createViewModel() + + viewModel.markOnboardingSeen() + + val seen = fakeOnboarding.hasSeenOnboarding().first() + assertEquals(true, seen) + } +} diff --git a/mobile/app/src/test/java/com/company/template/onboarding/OnboardingViewModelTest.kt b/mobile/app/src/test/java/com/company/template/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 0000000..0d0786e --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,68 @@ +package com.company.template.onboarding + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +// --------------- Fake repository --------------- + +class FakeOnboardingRepository : OnboardingRepository { + private val _seen = MutableStateFlow(false) + var markSeenCalled = false + + override fun hasSeenOnboarding(): Flow = _seen + + override suspend fun markSeen() { + markSeenCalled = true + _seen.value = true + } +} + +// --------------- Tests --------------- + +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingViewModelTest { + + private lateinit var fakeRepo: FakeOnboardingRepository + private lateinit var viewModel: OnboardingViewModel + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + fakeRepo = FakeOnboardingRepository() + viewModel = OnboardingViewModel(fakeRepo) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `hasSeenOnboarding emits false initially`() = runTest { + val value = viewModel.hasSeenOnboarding().first() + assertEquals(false, value) + } + + @Test + fun `markSeen calls repository and flow emits true`() = runTest { + var callbackInvoked = false + viewModel.markSeen(onComplete = { callbackInvoked = true }) + + assertTrue(fakeRepo.markSeenCalled) + assertTrue(callbackInvoked) + val value = viewModel.hasSeenOnboarding().first() + assertEquals(true, value) + } +} diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml index 6da749f..0c3eee8 100644 --- a/mobile/gradle/libs.versions.toml +++ b/mobile/gradle/libs.versions.toml @@ -14,6 +14,12 @@ firebaseBom = "33.7.0" okhttp = "4.12.0" kotlinxCoroutines = "1.10.2" kotlinxSerializationJson = "1.8.1" +navigationCompose = "2.9.0" +credentials = "1.5.0" +googleid = "1.1.1" +datastore = "1.1.7" +coil = "2.7.0" +lifecycleViewmodelCompose = "2.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -39,6 +45,15 @@ okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +firebase-auth-ktx = { group = "com.google.firebase", name = "firebase-auth-ktx" } +androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } +androidx-credentials-play-services = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" } +googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewmodelCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }