feat(android): implement auth screens, onboarding flow, and account setup

- Add AuthRepository with EncryptedSharedPreferences and OkHttp API calls
- Add AuthViewModel with login/signup/reset/Google Sign-In flows
- Create auth screens: AuthScreen, LoginScreen, SignupScreen,
  ForgotPasswordScreen, ResetPasswordScreen, BiometricAuthScreen
- Create onboarding screens with HorizontalPager: PlanSelection,
  WatchlistSetup, FamilyInvite, Complete (with checkmark animation)
- Wire auth state to navigation: unauthenticated→auth, new→onboarding,
  authenticated→dashboard
- Add PasswordStrength utility with tests
- Add dependencies: security-crypto, biometric, play-services-auth,
  okhttp, gson, lottie-compose, material-icons-core
- Add unit tests: 23 tests passing for AuthViewModel and PasswordStrength
This commit is contained in:
2026-05-25 20:24:33 -04:00
parent 325be03797
commit a90534e164
24 changed files with 2382 additions and 38 deletions

View File

@@ -50,8 +50,16 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation("androidx.compose.material:material-icons-core")
implementation(libs.coil.compose)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.biometric)
implementation(libs.play.services.auth)
implementation(libs.okhttp)
implementation(libs.gson)
implementation(libs.lottie.compose)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))

View File

@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ShieldAIApp"
android:allowBackup="true"

View File

@@ -1,9 +1,21 @@
package com.shieldai.android
import android.app.Application
import com.shieldai.android.data.repository.AuthRepository
import com.shieldai.android.data.repository.AuthRepositoryImpl
class ShieldAIApp : Application() {
lateinit var authRepository: AuthRepository
private set
override fun onCreate() {
super.onCreate()
instance = this
authRepository = AuthRepositoryImpl(this)
}
companion object {
lateinit var instance: ShieldAIApp
private set
}
}

View File

@@ -0,0 +1,162 @@
package com.shieldai.android.data.repository
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class AuthToken(
val accessToken: String,
val refreshToken: String? = null
)
data class User(
val id: String,
val name: String,
val email: String,
val isNewUser: Boolean = false
)
interface AuthRepository {
suspend fun login(email: String, password: String): Result<User>
suspend fun signup(name: String, email: String, password: String): Result<User>
suspend fun forgotPassword(email: String): Result<Unit>
suspend fun resetPassword(email: String, code: String, password: String): Result<Unit>
suspend fun signInWithGoogle(idToken: String): Result<User>
fun saveToken(accessToken: String, refreshToken: String?)
fun getAccessToken(): String?
fun getRefreshToken(): String?
fun clearTokens()
fun isLoggedIn(): Boolean
}
class AuthRepositoryImpl(
context: Context,
private val baseUrl: String = "https://api.shieldai.com"
) : AuthRepository {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"shieldai_auth_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private fun post(path: String, body: Map<String, String>): JSONObject {
val jsonBody = JSONObject(body).toString()
val request = Request.Builder()
.url("$baseUrl$path")
.post(jsonBody.toRequestBody(JSON_MEDIA_TYPE))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: throw Exception("Empty response")
if (!response.isSuccessful) {
val errorJson = try {
JSONObject(responseBody)
} catch (_: Exception) {
null
}
val message = errorJson?.optString("message", "Request failed") ?: "Request failed"
throw Exception(message)
}
return JSONObject(responseBody)
}
override suspend fun login(email: String, password: String): Result<User> = runCatching {
val json = post("/api/auth/login", mapOf(
"email" to email,
"password" to password
))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", false)
)
}
override suspend fun signup(name: String, email: String, password: String): Result<User> = runCatching {
val json = post("/api/auth/signup", mapOf(
"name" to name,
"email" to email,
"password" to password
))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", true)
)
}
override suspend fun forgotPassword(email: String): Result<Unit> = runCatching {
post("/api/auth/forgot-password", mapOf("email" to email))
Unit
}
override suspend fun resetPassword(email: String, code: String, password: String): Result<Unit> = runCatching {
post("/api/auth/reset-password", mapOf(
"email" to email,
"code" to code,
"password" to password
))
Unit
}
override suspend fun signInWithGoogle(idToken: String): Result<User> = runCatching {
val json = post("/api/auth/google", mapOf("idToken" to idToken))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", false)
)
}
override fun saveToken(accessToken: String, refreshToken: String?) {
securePrefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.apply()
}
override fun getAccessToken(): String? = securePrefs.getString("access_token", null)
override fun getRefreshToken(): String? = securePrefs.getString("refresh_token", null)
override fun clearTokens() {
securePrefs.edit()
.remove("access_token")
.remove("refresh_token")
.apply()
}
override fun isLoggedIn(): Boolean = getAccessToken() != null
}

View File

@@ -1,47 +1,75 @@
package com.shieldai.android.navigation
import android.app.Application
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun AppNavigation() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val bottomNavScreens = setOf(
Screen.Dashboard.route,
Screen.Services.route,
Screen.Alerts.route,
Screen.Settings.route,
Screen.Account.route
val context = LocalContext.current
val app = context.applicationContext as ShieldAIApp
val viewModel: AuthViewModel = viewModel(
factory = AuthViewModel.Factory
)
val showBottomBar = currentRoute in bottomNavScreens
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
val isNewUser by viewModel.isNewUser.collectAsState()
Scaffold(
bottomBar = {
if (showBottomBar) {
BottomNavBar(
currentRoute = currentRoute,
onNavigate = { screen ->
navController.navigate(screen.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
restoreState = true
}
if (isAuthenticated) {
if (isNewUser) {
OnboardingNavHost(
viewModel = viewModel,
onComplete = {
viewModel.completeOnboarding()
}
)
} else {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val bottomNavScreens = setOf(
Screen.Dashboard.route,
Screen.Services.route,
Screen.Alerts.route,
Screen.Settings.route,
Screen.Account.route
)
val showBottomBar = currentRoute in bottomNavScreens
Scaffold(
bottomBar = {
if (showBottomBar) {
BottomNavBar(
currentRoute = currentRoute,
onNavigate = { screen ->
navController.navigate(screen.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
restoreState = true
}
}
)
}
}
) { innerPadding ->
NavGraph(
navController = navController,
viewModel = viewModel,
modifier = Modifier.padding(innerPadding)
)
}
}
) { innerPadding ->
NavGraph(
navController = navController,
modifier = Modifier.padding(innerPadding)
)
} else {
AuthNavHost(viewModel = viewModel)
}
}

View File

@@ -12,11 +12,18 @@ import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.shieldai.android.ui.screens.auth.AuthScreen
import com.shieldai.android.ui.screens.auth.ForgotPasswordScreen
import com.shieldai.android.ui.screens.auth.ResetPasswordScreen
import com.shieldai.android.ui.screens.onboarding.OnboardingScreen
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun NavGraph(
navController: NavHostController,
viewModel: AuthViewModel,
modifier: Modifier = Modifier
) {
NavHost(
@@ -39,15 +46,6 @@ fun NavGraph(
composable(Screen.Account.route) {
PlaceholderScreen(title = "Account")
}
composable(Screen.Login.route) {
PlaceholderScreen(title = "Login")
}
composable(Screen.Signup.route) {
PlaceholderScreen(title = "Signup")
}
composable(Screen.Onboarding.route) {
PlaceholderScreen(title = "Onboarding")
}
composable(
route = Screen.ServiceDetail.ROUTE,
arguments = listOf(navArgument("serviceId") { type = NavType.StringType })
@@ -58,6 +56,55 @@ fun NavGraph(
}
}
@Composable
fun AuthNavHost(viewModel: AuthViewModel) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.Auth.route
) {
composable(Screen.Auth.route) {
AuthScreen(
viewModel = viewModel,
onNavigateToForgotPassword = {
navController.navigate(Screen.ForgotPassword.route)
},
onNavigateToResetPassword = {
navController.navigate(Screen.ResetPassword.createRoute(""))
}
)
}
composable(Screen.ForgotPassword.route) {
ForgotPasswordScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ResetPassword.route,
arguments = listOf(navArgument("email") { type = NavType.StringType; defaultValue = "" })
) { backStackEntry ->
val email = backStackEntry.arguments?.getString("email") ?: ""
ResetPasswordScreen(
viewModel = viewModel,
email = email,
onBack = { navController.popBackStack(Screen.Auth.route, false) }
)
}
}
}
@Composable
fun OnboardingNavHost(
viewModel: AuthViewModel,
onComplete: () -> Unit
) {
OnboardingScreen(
viewModel = viewModel,
onComplete = onComplete
)
}
@Composable
private fun PlaceholderScreen(title: String) {
Box(

View File

@@ -6,8 +6,11 @@ sealed class Screen(val route: String) {
data object Alerts : Screen("alerts")
data object Settings : Screen("settings")
data object Account : Screen("account")
data object Login : Screen("login")
data object Signup : Screen("signup")
data object Auth : Screen("auth")
data object ForgotPassword : Screen("forgot_password")
data object ResetPassword : Screen("reset_password/{email}") {
fun createRoute(email: String) = "reset_password/$email"
}
data object Onboarding : Screen("onboarding")
data class ServiceDetail(val serviceId: String) : Screen("service_detail/{serviceId}") {
companion object {

View File

@@ -0,0 +1,112 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.Image
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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.R
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun AuthScreen(
viewModel: AuthViewModel,
onNavigateToForgotPassword: () -> Unit,
onNavigateToResetPassword: () -> Unit
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Login", "Sign Up")
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(64.dp))
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "ShieldAI Logo",
modifier = Modifier.size(72.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "ShieldAI",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Your all-in-one digital protection",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
TabRow(
selectedTabIndex = selectedTab,
modifier = Modifier.fillMaxWidth()
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
ShieldCard(
modifier = Modifier.fillMaxWidth()
) {
if (selectedTab == 0) {
LoginScreen(
viewModel = viewModel,
onNavigateToForgotPassword = onNavigateToForgotPassword,
uiState = uiState
)
} else {
SignupScreen(
viewModel = viewModel,
uiState = uiState
)
}
}
}
}
}

View File

@@ -0,0 +1,89 @@
package com.shieldai.android.ui.screens.auth
import android.content.Context
import android.security.identity.IdentityCredentialException
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
@Composable
fun BiometricAuthScreen(
onAuthenticated: () -> Unit,
onError: (String) -> Unit,
title: String = "Biometric Authentication",
subtitle: String = "Authenticate to access ShieldAI",
description: String = "Use your fingerprint or face to sign in"
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
val biometricManager = remember {
BiometricManager.from(context)
}
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
val canAuthenticate = biometricManager.canAuthenticate(authenticators)
val promptInfo = remember {
BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setAllowedAuthenticators(authenticators)
.build()
}
DisposableEffect(activity) {
if (activity != null && canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) {
val biometricPrompt = BiometricPrompt(
activity,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onAuthenticated()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
errorCode != BiometricPrompt.ERROR_USER_CANCELED
) {
onError(errString.toString())
}
}
override fun onAuthenticationFailed() {
onError("Authentication failed")
}
}
)
biometricPrompt.authenticate(promptInfo)
}
onDispose { }
}
}
fun canUseBiometric(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
return biometricManager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun isBiometricEnabled(context: Context): Boolean {
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean("biometric_enabled", false)
}
fun setBiometricEnabled(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
prefs.edit().putBoolean("biometric_enabled", enabled).apply()
}

View File

@@ -0,0 +1,139 @@
package com.shieldai.android.ui.screens.auth
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun ForgotPasswordScreen(
viewModel: AuthViewModel,
onBack: () -> Unit
) {
var email by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
if (uiState.forgotPasswordSent) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Check Your Email",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "We've sent password reset instructions to $email",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}
} else {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Reset Password",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter your email address and we'll send you instructions to reset your password.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Send Reset Instructions",
onClick = { viewModel.forgotPassword(email) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = email.isNotBlank(),
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
variant = ShieldButtonVariant.Ghost,
fullWidth = true
)
}
}
}
}
}
}

View File

@@ -0,0 +1,183 @@
package com.shieldai.android.ui.screens.auth
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.viewmodel.AuthUiState
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onNavigateToForgotPassword: () -> Unit,
uiState: AuthUiState
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var rememberMe by remember { mutableStateOf(false) }
val context = LocalContext.current
val gso = remember {
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(context.getString(com.shieldai.android.R.string.default_web_client_id))
.requestEmail()
.build()
}
val googleSignInClient: GoogleSignInClient = remember {
GoogleSignIn.getClient(context, gso)
}
val googleSignInLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
try {
val account = task.getResult(ApiException::class.java)
account.idToken?.let { token ->
viewModel.signInWithGoogle(token)
}
} catch (_: ApiException) { }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = password,
onValueChange = { password = it },
label = "Password",
inputType = InputType.Password,
placeholder = "Enter your password"
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = rememberMe,
onCheckedChange = { rememberMe = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Remember me",
style = MaterialTheme.typography.bodySmall
)
}
TextButton(onClick = onNavigateToForgotPassword) {
Text(
text = "Forgot password?",
style = MaterialTheme.typography.bodySmall,
color = BrandPrimary
)
}
}
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Sign In",
onClick = { viewModel.login(email, password) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "or continue with",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = {
val signInIntent = googleSignInClient.signInIntent
googleSignInLauncher.launch(signInIntent)
},
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface)
) {
Text(
text = "Sign in with Google",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
}
}
}

View File

@@ -0,0 +1,151 @@
package com.shieldai.android.ui.screens.auth
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun ResetPasswordScreen(
viewModel: AuthViewModel,
email: String,
onBack: () -> Unit
) {
var code by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
if (uiState.resetPasswordSuccess) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Password Reset Successful",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your password has been reset. You can now log in with your new password.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}
} else {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Set New Password",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter the reset code sent to your email and your new password.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
ShieldTextField(
value = code,
onValueChange = { code = it },
label = "Reset Code",
placeholder = "Enter the code from email"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = "New Password",
inputType = InputType.Password,
placeholder = "Enter new password"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "Confirm New Password",
inputType = InputType.Password,
placeholder = "Re-enter new password",
isError = confirmPassword.isNotEmpty() && newPassword != confirmPassword,
errorMessage = if (confirmPassword.isNotEmpty() && newPassword != confirmPassword) "Passwords do not match" else null
)
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Reset Password",
onClick = { viewModel.resetPassword(email, code, newPassword) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = code.isNotBlank() && newPassword.isNotBlank()
&& newPassword == confirmPassword,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ProgressColor
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldProgressBar
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.util.PasswordStrength
import com.shieldai.android.util.calculatePasswordStrength
import com.shieldai.android.util.passwordStrengthLabel
import com.shieldai.android.viewmodel.AuthUiState
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun SignupScreen(
viewModel: AuthViewModel,
uiState: AuthUiState
) {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var acceptTerms by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
ShieldTextField(
value = name,
onValueChange = { name = it },
label = "Full Name",
placeholder = "John Doe"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = password,
onValueChange = {
password = it
viewModel.updatePasswordStrength(it)
},
label = "Password",
inputType = InputType.Password,
placeholder = "Create a strong password"
)
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
val strength = calculatePasswordStrength(password)
ShieldProgressBar(
progress = when (strength) {
PasswordStrength.WEAK -> 0.25f
PasswordStrength.FAIR -> 0.5f
PasswordStrength.STRONG -> 0.75f
PasswordStrength.VERY_STRONG -> 1.0f
},
color = when (strength) {
PasswordStrength.WEAK -> ProgressColor.Error
PasswordStrength.FAIR -> ProgressColor.Warning
PasswordStrength.STRONG -> ProgressColor.Success
PasswordStrength.VERY_STRONG -> ProgressColor.Success
},
showPercentage = false
)
Text(
text = "Password strength: ${passwordStrengthLabel(strength)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "Confirm Password",
inputType = InputType.Password,
placeholder = "Re-enter your password",
isError = confirmPassword.isNotEmpty() && password != confirmPassword,
errorMessage = if (confirmPassword.isNotEmpty() && password != confirmPassword) "Passwords do not match" else null
)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptTerms,
onCheckedChange = { acceptTerms = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "I accept the Terms of Service and Privacy Policy",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Create Account",
onClick = { viewModel.signup(name, email, password) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = name.isNotBlank() && email.isNotBlank() && password.isNotBlank()
&& password == confirmPassword && acceptTerms,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}

View File

@@ -0,0 +1,135 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
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.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Success
@Composable
fun CompleteStep(onComplete: () -> Unit) {
val animatedProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatedProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 1000)
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
) {
Canvas(
modifier = Modifier.size(120.dp)
) {
val strokeWidth = 8.dp.toPx()
val radius = (size.minDimension - strokeWidth) / 2
val center = Offset(size.width / 2, size.height / 2)
drawCircle(
color = Color.LightGray.copy(alpha = 0.3f),
radius = radius,
center = center,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
drawArc(
color = Success,
startAngle = -90f,
sweepAngle = 360f * animatedProgress.value,
useCenter = false,
topLeft = Offset(center.x - radius, center.y - radius),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
if (animatedProgress.value >= 0.5f) {
val checkProgress = (animatedProgress.value - 0.5f) * 2f
val startX = center.x - radius * 0.35f
val midX = center.x - radius * 0.05f
val endX = center.x + radius * 0.5f
val startY = center.y
val midY = center.y + radius * 0.35f * checkProgress
val endY = center.y - radius * 0.3f * checkProgress
drawLine(
color = Success,
start = Offset(startX, startY),
end = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
strokeWidth = 6.dp.toPx(),
cap = StrokeCap.Round
)
if (animatedProgress.value >= 0.75f) {
val endCheckProgress = (animatedProgress.value - 0.75f) * 4f
drawLine(
color = Success,
start = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
end = Offset(
startX + (endX - startX) * endCheckProgress * 0.5f + radius * 0.5f * endCheckProgress,
endY.coerceAtLeast(startY - radius * 0.3f * endCheckProgress)
),
strokeWidth = 6.dp.toPx(),
cap = StrokeCap.Round
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "You're All Set!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your account is ready. Start monitoring your digital footprint and stay protected.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(40.dp))
ShieldButton(
text = "Get Started",
onClick = onComplete,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}

View File

@@ -0,0 +1,132 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
@Composable
fun FamilyInviteStep(
invites: List<String>,
onAddInvite: (String) -> Unit,
onRemoveInvite: (Int) -> Unit
) {
var emailInput by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Invite Family Members",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Protect your family too. Add their emails to include them in your plan.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
ShieldTextField(
value = emailInput,
onValueChange = { emailInput = it },
label = "Family Email",
inputType = InputType.Email,
placeholder = "family@example.com",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
ShieldButton(
text = "Invite",
onClick = {
if (emailInput.isNotBlank()) {
onAddInvite(emailInput.trim())
emailInput = ""
}
},
variant = ShieldButtonVariant.Primary,
enabled = emailInput.isNotBlank(),
modifier = Modifier.padding(top = 6.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (invites.isEmpty()) {
Text(
text = "No invites sent yet. You can skip this step and invite later.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
)
} else {
invites.forEachIndexed { index, email ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = email,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onRemoveInvite(index) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "You can always invite more family members later from Settings.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@@ -0,0 +1,120 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun OnboardingScreen(
viewModel: AuthViewModel,
onComplete: () -> Unit
) {
val pagerState = rememberPagerState(pageCount = { 4 })
val onboardingData by viewModel.onboardingData.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { page ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
when (page) {
0 -> PlanSelectionStep(
selectedPlan = onboardingData.selectedPlan,
onPlanSelected = { plan ->
viewModel.updateOnboardingData {
it.copy(selectedPlan = plan)
}
}
)
1 -> WatchlistSetupStep(
watchlistItems = onboardingData.watchlistItems,
onAddItem = { item ->
viewModel.updateOnboardingData {
it.copy(watchlistItems = it.watchlistItems + item)
}
},
onRemoveItem = { index ->
viewModel.updateOnboardingData {
it.copy(watchlistItems = it.watchlistItems.toMutableList().apply { removeAt(index) })
}
}
)
2 -> FamilyInviteStep(
invites = onboardingData.familyInvites,
onAddInvite = { email ->
viewModel.updateOnboardingData {
it.copy(familyInvites = it.familyInvites + email)
}
},
onRemoveInvite = { index ->
viewModel.updateOnboardingData {
it.copy(familyInvites = it.familyInvites.toMutableList().apply { removeAt(index) })
}
}
)
3 -> CompleteStep(onComplete = onComplete)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(4) { index ->
val isSelected = pagerState.currentPage == index
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(if (isSelected) 10.dp else 8.dp)
.clip(CircleShape)
.drawBehind {
drawCircle(
color = if (isSelected) BrandPrimary
else Color.Gray.copy(alpha = 0.3f)
)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,181 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
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.font.FontWeight
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
data class Plan(
val name: String,
val price: String,
val features: List<String>,
val description: String
)
private val plans = listOf(
Plan(
name = "Basic",
price = "Free",
features = listOf(
"Monitor 1 email/phone",
"Basic alerts",
"7-day data history"
),
description = "Essential protection"
),
Plan(
name = "Plus",
price = "$9.99/mo",
features = listOf(
"Monitor up to 5 emails/phones",
"Real-time alerts",
"30-day data history",
"Family sharing (2 members)"
),
description = "Enhanced protection"
),
Plan(
name = "Premium",
price = "$19.99/mo",
features = listOf(
"Unlimited monitoring",
"Priority alerts",
"90-day data history",
"Family sharing (5 members)",
"Dark web monitoring",
"Identity restoration support"
),
description = "Maximum protection"
)
)
@Composable
fun PlanSelectionStep(
selectedPlan: String,
onPlanSelected: (String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Choose Your Plan",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Select the plan that fits your needs. You can upgrade or change anytime.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
plans.forEach { plan ->
val isSelected = selectedPlan == plan.name
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable { onPlanSelected(plan.name) },
shape = MaterialTheme.shapes.medium,
border = BorderStroke(
width = if (isSelected) 2.dp else 1.dp,
color = if (isSelected) BrandPrimary else MaterialTheme.colorScheme.outline
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
BrandPrimary.copy(alpha = 0.08f)
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
RadioButton(
selected = isSelected,
onClick = { onPlanSelected(plan.name) },
colors = RadioButtonDefaults.colors(
selectedColor = BrandPrimary
)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = plan.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = plan.price,
style = MaterialTheme.typography.titleMedium,
color = BrandPrimary,
fontWeight = FontWeight.Bold
)
}
Text(
text = plan.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
plan.features.forEach { feature ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "",
color = BrandPrimary,
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = feature,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,123 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
@Composable
fun WatchlistSetupStep(
watchlistItems: List<String>,
onAddItem: (String) -> Unit,
onRemoveItem: (Int) -> Unit
) {
var inputValue by remember { mutableStateOf("") }
var inputType by remember { mutableStateOf(InputType.Email) }
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Set Up Your Watchlist",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add email addresses or phone numbers you want to monitor for breaches and leaks.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
ShieldTextField(
value = inputValue,
onValueChange = { inputValue = it },
label = "Email or Phone",
inputType = inputType,
placeholder = "you@example.com or +1234567890",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
ShieldButton(
text = "Add",
onClick = {
if (inputValue.isNotBlank()) {
onAddItem(inputValue.trim())
inputValue = ""
}
},
variant = ShieldButtonVariant.Primary,
enabled = inputValue.isNotBlank(),
modifier = Modifier.padding(top = 6.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (watchlistItems.isEmpty()) {
Text(
text = "No items added yet. Add emails or phones to start monitoring.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
)
} else {
watchlistItems.forEachIndexed { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = item,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onRemoveItem(index) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
package com.shieldai.android.util
import androidx.compose.ui.graphics.Color
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.Warning
enum class PasswordStrength {
WEAK, FAIR, STRONG, VERY_STRONG
}
fun calculatePasswordStrength(password: String): PasswordStrength {
if (password.length < 6) return PasswordStrength.WEAK
var score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (password.any { it.isUpperCase() }) score++
if (password.any { it.isLowerCase() }) score++
if (password.any { it.isDigit() }) score++
if (password.any { !it.isLetterOrDigit() }) score++
return when {
score <= 2 -> PasswordStrength.WEAK
score == 3 -> PasswordStrength.FAIR
score == 4 -> PasswordStrength.STRONG
else -> PasswordStrength.VERY_STRONG
}
}
fun passwordStrengthProgress(strength: PasswordStrength): Float = when (strength) {
PasswordStrength.WEAK -> 0.25f
PasswordStrength.FAIR -> 0.5f
PasswordStrength.STRONG -> 0.75f
PasswordStrength.VERY_STRONG -> 1.0f
}
fun passwordStrengthLabel(strength: PasswordStrength): String = when (strength) {
PasswordStrength.WEAK -> "Weak"
PasswordStrength.FAIR -> "Fair"
PasswordStrength.STRONG -> "Strong"
PasswordStrength.VERY_STRONG -> "Very Strong"
}
fun passwordStrengthColor(strength: PasswordStrength): Color = when (strength) {
PasswordStrength.WEAK -> Error
PasswordStrength.FAIR -> Warning
PasswordStrength.STRONG -> Success
PasswordStrength.VERY_STRONG -> Success
}

View File

@@ -0,0 +1,196 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.repository.AuthRepository
import com.shieldai.android.data.repository.AuthRepositoryImpl
import com.shieldai.android.data.repository.User
import com.shieldai.android.util.calculatePasswordStrength
import com.shieldai.android.util.passwordStrengthProgress
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class AuthUiState(
val isLoading: Boolean = false,
val error: String? = null,
val user: User? = null,
val forgotPasswordSent: Boolean = false,
val resetPasswordSuccess: Boolean = false,
val passwordStrength: Float = 0f
)
data class OnboardingData(
val selectedPlan: String = "Basic",
val watchlistItems: List<String> = emptyList(),
val familyInvites: List<String> = emptyList()
)
class AuthViewModel(
private val repository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _isAuthenticated = MutableStateFlow(repository.isLoggedIn())
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
private val _isNewUser = MutableStateFlow(false)
val isNewUser: StateFlow<Boolean> = _isNewUser.asStateFlow()
private val _onboardingData = MutableStateFlow(OnboardingData())
val onboardingData: StateFlow<OnboardingData> = _onboardingData.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.login(email, password)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Login failed"
)
}
)
}
}
fun signup(name: String, email: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.signup(name, email, password)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Signup failed"
)
}
)
}
}
fun forgotPassword(email: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, forgotPasswordSent = false)
val result = repository.forgotPassword(email)
result.fold(
onSuccess = {
_uiState.value = _uiState.value.copy(isLoading = false, forgotPasswordSent = true)
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Request failed"
)
}
)
}
}
fun resetPassword(email: String, code: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, resetPasswordSuccess = false)
val result = repository.resetPassword(email, code, password)
result.fold(
onSuccess = {
_uiState.value = _uiState.value.copy(isLoading = false, resetPasswordSuccess = true)
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Reset failed"
)
}
)
}
}
fun signInWithGoogle(idToken: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.signInWithGoogle(idToken)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Google Sign-In failed"
)
}
)
}
}
fun logout() {
repository.clearTokens()
_uiState.value = AuthUiState()
_isAuthenticated.value = false
_isNewUser.value = false
_onboardingData.value = OnboardingData()
}
fun updatePasswordStrength(password: String) {
val strength = calculatePasswordStrength(password)
_uiState.value = _uiState.value.copy(
passwordStrength = passwordStrengthProgress(strength)
)
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
fun updateOnboardingData(update: (OnboardingData) -> OnboardingData) {
_onboardingData.value = update(_onboardingData.value)
}
fun completeOnboarding() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val data = _onboardingData.value
try {
repository.saveToken(
repository.getAccessToken() ?: throw Exception("Not authenticated"),
repository.getRefreshToken()
)
_isNewUser.value = false
_uiState.value = _uiState.value.copy(isLoading = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to complete onboarding"
)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val app = ShieldAIApp.instance
return AuthViewModel(app.authRepository) as T
}
}
}
}

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">ShieldAI</string>
<string name="default_web_client_id" translatable="false">REPLACE_WITH_YOUR_WEB_CLIENT_ID</string>
</resources>

View File

@@ -0,0 +1,79 @@
package com.shieldai.android.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PasswordStrengthTest {
@Test
fun emptyPassword_isWeak() {
assertEquals(PasswordStrength.WEAK, calculatePasswordStrength(""))
}
@Test
fun shortPassword_isWeak() {
assertEquals(PasswordStrength.WEAK, calculatePasswordStrength("Ab1"))
}
@Test
fun onlyLowercase_isWeak() {
assertEquals(PasswordStrength.WEAK, calculatePasswordStrength("abcdefgh"))
}
@Test
fun eightCharsWithMixed_isFair() {
assertEquals(PasswordStrength.FAIR, calculatePasswordStrength("Abcdefgh"))
}
@Test
fun eightCharsWithMixedAndDigit_isStrong() {
assertEquals(PasswordStrength.STRONG, calculatePasswordStrength("Abcdefg1"))
}
@Test
fun twelveCharsMixed_isVeryStrong() {
assertEquals(PasswordStrength.VERY_STRONG, calculatePasswordStrength("Abcdefghij12"))
}
@Test
fun longAndComplex_isVeryStrong() {
assertEquals(PasswordStrength.VERY_STRONG, calculatePasswordStrength("Abcdefghijklm1@3"))
}
@Test
fun withSpecialChar_boostsStrength() {
val withSpecial = calculatePasswordStrength("Password1!")
val withoutSpecial = calculatePasswordStrength("Password12")
assertTrue(
"Password with special chars should be stronger or equal",
withSpecial.ordinal >= withoutSpecial.ordinal
)
}
@Test
fun withUpperCase_boostsStrength() {
val withUpper = calculatePasswordStrength("Password1!")
val withoutUpper = calculatePasswordStrength("password1!")
assertTrue(
"Password with uppercase should be stronger or equal",
withUpper.ordinal >= withoutUpper.ordinal
)
}
@Test
fun passwordStrengthProgress_returnsCorrectValues() {
assertEquals(0.25f, passwordStrengthProgress(PasswordStrength.WEAK), 0.001f)
assertEquals(0.5f, passwordStrengthProgress(PasswordStrength.FAIR), 0.001f)
assertEquals(0.75f, passwordStrengthProgress(PasswordStrength.STRONG), 0.001f)
assertEquals(1.0f, passwordStrengthProgress(PasswordStrength.VERY_STRONG), 0.001f)
}
@Test
fun passwordStrengthLabel_returnsCorrectStrings() {
assertEquals("Weak", passwordStrengthLabel(PasswordStrength.WEAK))
assertEquals("Fair", passwordStrengthLabel(PasswordStrength.FAIR))
assertEquals("Strong", passwordStrengthLabel(PasswordStrength.STRONG))
assertEquals("Very Strong", passwordStrengthLabel(PasswordStrength.VERY_STRONG))
}
}

View File

@@ -0,0 +1,226 @@
package com.shieldai.android.viewmodel
import com.shieldai.android.data.repository.AuthRepository
import com.shieldai.android.data.repository.User
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
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.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AuthViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var fakeRepository: FakeAuthRepository
private lateinit var viewModel: AuthViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeRepository = FakeAuthRepository()
viewModel = AuthViewModel(fakeRepository)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun login_emitsLoadingThenSuccess() = testScope.runTest {
fakeRepository.setLoginResult(Result.success(testUser()))
viewModel.login("test@example.com", "password123")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not be loading after completion", state.isLoading)
assertNull("Should have no error", state.error)
assertEquals(testUser().email, state.user?.email)
assertTrue("Should be authenticated", viewModel.isAuthenticated.value)
}
@Test
fun login_withFailure_emitsError() = testScope.runTest {
fakeRepository.setLoginResult(Result.failure(Exception("Invalid credentials")))
viewModel.login("test@example.com", "wrong")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not be loading after completion", state.isLoading)
assertEquals("Invalid credentials", state.error)
assertFalse("Should not be authenticated", viewModel.isAuthenticated.value)
}
@Test
fun signup_emitsLoadingThenSuccess() = testScope.runTest {
fakeRepository.setSignupResult(Result.success(testUser(isNewUser = true)))
viewModel.signup("Test User", "test@example.com", "password123")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not be loading", state.isLoading)
assertNull("Should have no error", state.error)
assertTrue("Should be authenticated", viewModel.isAuthenticated.value)
assertTrue("Should be new user", viewModel.isNewUser.value)
}
@Test
fun forgotPassword_emitsSuccessState() = testScope.runTest {
fakeRepository.setForgotPasswordResult(Result.success(Unit))
viewModel.forgotPassword("test@example.com")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not be loading", state.isLoading)
assertTrue("forgotPasswordSent should be true", state.forgotPasswordSent)
}
@Test
fun resetPassword_emitsSuccessState() = testScope.runTest {
fakeRepository.setResetPasswordResult(Result.success(Unit))
viewModel.resetPassword("test@example.com", "123456", "newPassword123")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not be loading", state.isLoading)
assertTrue("resetPasswordSuccess should be true", state.resetPasswordSuccess)
}
@Test
fun logout_clearsState() = testScope.runTest {
fakeRepository.setLoginResult(Result.success(testUser()))
viewModel.login("test@example.com", "password123")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue("Should be authenticated after login", viewModel.isAuthenticated.value)
viewModel.logout()
assertFalse("Should not be authenticated after logout", viewModel.isAuthenticated.value)
assertFalse("Should not be new user after logout", viewModel.isNewUser.value)
val state = viewModel.uiState.value
assertNull("User should be null after logout", state.user)
assertNull("Error should be null after logout", state.error)
}
@Test
fun signInWithGoogle_emitsLoadingThenSuccess() = testScope.runTest {
fakeRepository.setGoogleSignInResult(Result.success(testUser()))
viewModel.signInWithGoogle("google-id-token")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not be loading", state.isLoading)
assertNull("Should have no error", state.error)
assertTrue("Should be authenticated", viewModel.isAuthenticated.value)
}
@Test
fun updatePasswordStrength_calculatesCorrectly() {
viewModel.updatePasswordStrength("weak")
assertEquals(0.25f, viewModel.uiState.value.passwordStrength, 0.001f)
viewModel.updatePasswordStrength("WeakPwd1")
assertEquals(0.75f, viewModel.uiState.value.passwordStrength, 0.001f)
}
@Test
fun updateOnboardingData_collectsData() {
viewModel.updateOnboardingData { it.copy(selectedPlan = "Premium") }
assertEquals("Premium", viewModel.onboardingData.value.selectedPlan)
viewModel.updateOnboardingData { it.copy(watchlistItems = listOf("test@example.com")) }
assertEquals(1, viewModel.onboardingData.value.watchlistItems.size)
assertEquals("test@example.com", viewModel.onboardingData.value.watchlistItems[0])
}
@Test
fun completeOnboarding_clearsNewUserFlag() = testScope.runTest {
fakeRepository.setLoginResult(Result.success(testUser(isNewUser = true)))
fakeRepository.setAccessTokenForTest("test-token")
viewModel.login("test@example.com", "password")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.completeOnboarding()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not be loading", state.isLoading)
}
@Test
fun clearError_resetsErrorState() {
viewModel.login("test@example.com", "wrong")
viewModel.clearError()
assertNull(viewModel.uiState.value.error)
}
private fun testUser(
id: String = "user-1",
name: String = "Test User",
email: String = "test@example.com",
isNewUser: Boolean = false
) = User(id = id, name = name, email = email, isNewUser = isNewUser)
}
class FakeAuthRepository : AuthRepository {
private var loginResult: Result<User> = Result.failure(Exception("Not configured"))
private var signupResult: Result<User> = Result.failure(Exception("Not configured"))
private var forgotPasswordResult: Result<Unit> = Result.failure(Exception("Not configured"))
private var resetPasswordResult: Result<Unit> = Result.failure(Exception("Not configured"))
private var googleSignInResult: Result<User> = Result.failure(Exception("Not configured"))
private var storedToken: String? = null
private var storedRefreshToken: String? = null
fun setLoginResult(result: Result<User>) { loginResult = result }
fun setSignupResult(result: Result<User>) { signupResult = result }
fun setForgotPasswordResult(result: Result<Unit>) { forgotPasswordResult = result }
fun setResetPasswordResult(result: Result<Unit>) { resetPasswordResult = result }
fun setGoogleSignInResult(result: Result<User>) { googleSignInResult = result }
fun setAccessTokenForTest(token: String) { storedToken = token }
override suspend fun login(email: String, password: String): Result<User> = loginResult
override suspend fun signup(name: String, email: String, password: String): Result<User> = signupResult
override suspend fun forgotPassword(email: String): Result<Unit> = forgotPasswordResult
override suspend fun resetPassword(email: String, code: String, password: String): Result<Unit> = resetPasswordResult
override suspend fun signInWithGoogle(idToken: String): Result<User> = googleSignInResult
override fun saveToken(accessToken: String, refreshToken: String?) {
storedToken = accessToken
storedRefreshToken = refreshToken
}
override fun getAccessToken(): String? = storedToken
override fun getRefreshToken(): String? = storedRefreshToken
override fun clearTokens() {
storedToken = null
storedRefreshToken = null
}
override fun isLoggedIn(): Boolean = storedToken != null
}

View File

@@ -10,6 +10,13 @@ navigationCompose = "2.7.7"
kotlin = "2.2.10"
composeBom = "2025.12.00"
coilCompose = "2.7.0"
securityCrypto = "1.1.0-alpha06"
biometric = "1.2.0-alpha05"
playServicesAuth = "21.0.0"
okhttp = "4.12.0"
gson = "2.10.1"
lottieCompose = "6.4.0"
coroutinesTest = "1.7.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -29,6 +36,13 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }