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:
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user