From a90534e164468c9a48268c34fc3bccd6c7f04d70 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 20:24:33 -0400 Subject: [PATCH] feat(android): implement auth screens, onboarding flow, and account setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- android/ShieldAI/app/build.gradle.kts | 8 + .../ShieldAI/app/src/main/AndroidManifest.xml | 2 + .../java/com/shieldai/android/ShieldAIApp.kt | 12 + .../android/data/repository/AuthRepository.kt | 162 +++++++++++++ .../android/navigation/AppNavigation.kt | 82 ++++--- .../shieldai/android/navigation/NavGraph.kt | 65 ++++- .../com/shieldai/android/navigation/Screen.kt | 7 +- .../android/ui/screens/auth/AuthScreen.kt | 112 +++++++++ .../ui/screens/auth/BiometricAuthScreen.kt | 89 +++++++ .../ui/screens/auth/ForgotPasswordScreen.kt | 139 +++++++++++ .../android/ui/screens/auth/LoginScreen.kt | 183 ++++++++++++++ .../ui/screens/auth/ResetPasswordScreen.kt | 151 ++++++++++++ .../android/ui/screens/auth/SignupScreen.kt | 153 ++++++++++++ .../ui/screens/onboarding/CompleteStep.kt | 135 +++++++++++ .../ui/screens/onboarding/FamilyInviteStep.kt | 132 ++++++++++ .../ui/screens/onboarding/OnboardingScreen.kt | 120 ++++++++++ .../screens/onboarding/PlanSelectionStep.kt | 181 ++++++++++++++ .../screens/onboarding/WatchlistSetupStep.kt | 123 ++++++++++ .../shieldai/android/util/PasswordStrength.kt | 48 ++++ .../android/viewmodel/AuthViewModel.kt | 196 +++++++++++++++ .../app/src/main/res/values/strings.xml | 1 + .../android/util/PasswordStrengthTest.kt | 79 ++++++ .../android/viewmodel/AuthViewModelTest.kt | 226 ++++++++++++++++++ android/ShieldAI/gradle/libs.versions.toml | 14 ++ 24 files changed, 2382 insertions(+), 38 deletions(-) create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/AuthRepository.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/AuthScreen.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/BiometricAuthScreen.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ForgotPasswordScreen.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/LoginScreen.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ResetPasswordScreen.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/SignupScreen.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/CompleteStep.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/FamilyInviteStep.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/OnboardingScreen.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/PlanSelectionStep.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/WatchlistSetupStep.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/util/PasswordStrength.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/viewmodel/AuthViewModel.kt create mode 100644 android/ShieldAI/app/src/test/java/com/shieldai/android/util/PasswordStrengthTest.kt create mode 100644 android/ShieldAI/app/src/test/java/com/shieldai/android/viewmodel/AuthViewModelTest.kt diff --git a/android/ShieldAI/app/build.gradle.kts b/android/ShieldAI/app/build.gradle.kts index 905c704..9a655de 100644 --- a/android/ShieldAI/app/build.gradle.kts +++ b/android/ShieldAI/app/build.gradle.kts @@ -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)) diff --git a/android/ShieldAI/app/src/main/AndroidManifest.xml b/android/ShieldAI/app/src/main/AndroidManifest.xml index 9b03a97..2cde9f7 100644 --- a/android/ShieldAI/app/src/main/AndroidManifest.xml +++ b/android/ShieldAI/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + suspend fun signup(name: String, email: String, password: String): Result + suspend fun forgotPassword(email: String): Result + suspend fun resetPassword(email: String, code: String, password: String): Result + suspend fun signInWithGoogle(idToken: String): Result + 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): 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 = 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 = 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 = runCatching { + post("/api/auth/forgot-password", mapOf("email" to email)) + Unit + } + + override suspend fun resetPassword(email: String, code: String, password: String): Result = runCatching { + post("/api/auth/reset-password", mapOf( + "email" to email, + "code" to code, + "password" to password + )) + Unit + } + + override suspend fun signInWithGoogle(idToken: String): Result = 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 +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/AppNavigation.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/AppNavigation.kt index 2fbb44c..03a9521 100644 --- a/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/AppNavigation.kt +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/AppNavigation.kt @@ -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) } } diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/NavGraph.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/NavGraph.kt index 9c91dc7..f18ce61 100644 --- a/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/NavGraph.kt +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/NavGraph.kt @@ -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( diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/Screen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/Screen.kt index 685c27d..85cad4b 100644 --- a/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/Screen.kt +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/navigation/Screen.kt @@ -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 { diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/AuthScreen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/AuthScreen.kt new file mode 100644 index 0000000..e0e157c --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/AuthScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/BiometricAuthScreen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/BiometricAuthScreen.kt new file mode 100644 index 0000000..fc6b3e4 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/BiometricAuthScreen.kt @@ -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() +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ForgotPasswordScreen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ForgotPasswordScreen.kt new file mode 100644 index 0000000..07502df --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ForgotPasswordScreen.kt @@ -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 + ) + } + } + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/LoginScreen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/LoginScreen.kt new file mode 100644 index 0000000..6aa6d6d --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/LoginScreen.kt @@ -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 + ) + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ResetPasswordScreen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ResetPasswordScreen.kt new file mode 100644 index 0000000..5248c67 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/ResetPasswordScreen.kt @@ -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() + ) + } + } + } + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/SignupScreen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/SignupScreen.kt new file mode 100644 index 0000000..8c6d533 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/auth/SignupScreen.kt @@ -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() + ) + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/CompleteStep.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/CompleteStep.kt new file mode 100644 index 0000000..ab2f2e1 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/CompleteStep.kt @@ -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 + ) + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/FamilyInviteStep.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/FamilyInviteStep.kt new file mode 100644 index 0000000..68e790f --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/FamilyInviteStep.kt @@ -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, + 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() + ) + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/OnboardingScreen.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..1353d05 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/OnboardingScreen.kt @@ -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) + ) + } + ) + } + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/PlanSelectionStep.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/PlanSelectionStep.kt new file mode 100644 index 0000000..8e6152f --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/PlanSelectionStep.kt @@ -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, + 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 + ) + } + } + } + } + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/WatchlistSetupStep.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/WatchlistSetupStep.kt new file mode 100644 index 0000000..3616471 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/screens/onboarding/WatchlistSetupStep.kt @@ -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, + 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 + ) + } + } + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/util/PasswordStrength.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/util/PasswordStrength.kt new file mode 100644 index 0000000..7549296 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/util/PasswordStrength.kt @@ -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 +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/viewmodel/AuthViewModel.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/viewmodel/AuthViewModel.kt new file mode 100644 index 0000000..be8d5f8 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/viewmodel/AuthViewModel.kt @@ -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 = emptyList(), + val familyInvites: List = emptyList() +) + +class AuthViewModel( + private val repository: AuthRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _isAuthenticated = MutableStateFlow(repository.isLoggedIn()) + val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + private val _isNewUser = MutableStateFlow(false) + val isNewUser: StateFlow = _isNewUser.asStateFlow() + + private val _onboardingData = MutableStateFlow(OnboardingData()) + val onboardingData: StateFlow = _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 create(modelClass: Class): T { + val app = ShieldAIApp.instance + return AuthViewModel(app.authRepository) as T + } + } + } +} diff --git a/android/ShieldAI/app/src/main/res/values/strings.xml b/android/ShieldAI/app/src/main/res/values/strings.xml index 27135d5..00849b9 100644 --- a/android/ShieldAI/app/src/main/res/values/strings.xml +++ b/android/ShieldAI/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ ShieldAI + REPLACE_WITH_YOUR_WEB_CLIENT_ID diff --git a/android/ShieldAI/app/src/test/java/com/shieldai/android/util/PasswordStrengthTest.kt b/android/ShieldAI/app/src/test/java/com/shieldai/android/util/PasswordStrengthTest.kt new file mode 100644 index 0000000..cee55b0 --- /dev/null +++ b/android/ShieldAI/app/src/test/java/com/shieldai/android/util/PasswordStrengthTest.kt @@ -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)) + } +} diff --git a/android/ShieldAI/app/src/test/java/com/shieldai/android/viewmodel/AuthViewModelTest.kt b/android/ShieldAI/app/src/test/java/com/shieldai/android/viewmodel/AuthViewModelTest.kt new file mode 100644 index 0000000..90a4b7d --- /dev/null +++ b/android/ShieldAI/app/src/test/java/com/shieldai/android/viewmodel/AuthViewModelTest.kt @@ -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 = Result.failure(Exception("Not configured")) + private var signupResult: Result = Result.failure(Exception("Not configured")) + private var forgotPasswordResult: Result = Result.failure(Exception("Not configured")) + private var resetPasswordResult: Result = Result.failure(Exception("Not configured")) + private var googleSignInResult: Result = Result.failure(Exception("Not configured")) + private var storedToken: String? = null + private var storedRefreshToken: String? = null + + fun setLoginResult(result: Result) { loginResult = result } + fun setSignupResult(result: Result) { signupResult = result } + fun setForgotPasswordResult(result: Result) { forgotPasswordResult = result } + fun setResetPasswordResult(result: Result) { resetPasswordResult = result } + fun setGoogleSignInResult(result: Result) { googleSignInResult = result } + fun setAccessTokenForTest(token: String) { storedToken = token } + + override suspend fun login(email: String, password: String): Result = loginResult + override suspend fun signup(name: String, email: String, password: String): Result = signupResult + override suspend fun forgotPassword(email: String): Result = forgotPasswordResult + override suspend fun resetPassword(email: String, code: String, password: String): Result = resetPasswordResult + override suspend fun signInWithGoogle(idToken: String): Result = 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 +} diff --git a/android/ShieldAI/gradle/libs.versions.toml b/android/ShieldAI/gradle/libs.versions.toml index 7b24a70..b4b5180 100644 --- a/android/ShieldAI/gradle/libs.versions.toml +++ b/android/ShieldAI/gradle/libs.versions.toml @@ -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" }