diff --git a/README.md b/README.md index 3051ac0..fd2f88d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Test workspace for [FlutterProbe](https://github.com/AlphaWaveSystems/flutter-pr ## Structure ``` -mobile/ Flutter test app (iOS + Android) — 11 screens, 78 E2E tests +mobile/ Flutter test app (iOS + Android) — 11 screens, 78 E2E tests +native/android-kotlin/ Native Android twin (Kotlin + Jetpack Compose) — same 11 screens, for native-verb testing ``` ## Mobile App @@ -28,6 +29,17 @@ The `BiometricLoginPage` exercises the new `enroll biometric` / `biometric match - Android: emulator with fingerprint ID `1` pre-enrolled in Settings → Security - Physical devices skip these steps with a warning (`set location`-style behavior) +## Native Android Twin + +[`native/android-kotlin/`](native/android-kotlin/) is a full native Android replica of the Flutter app (Kotlin, Jetpack Compose, applicationId `com.alphawavesystems.probe_test_app_native`) built to exercise FlutterProbe's **native verb family** (`tap native`, `type native`, `see native`, `don't see native`) against a real non-Flutter app. Every interactive/assertable view carries a Compose `testTag` that mirrors the Flutter app's `ValueKey` names 1:1 and is surfaced as a uiautomator `resource-id` (`testTagsAsResourceId`), so the same element vocabulary works across both apps. The Biometric Login screen uses the real Android `BiometricPrompt`, driven by `biometric match` / `biometric no match`. Its native-verb suite lives in [`native/android-kotlin/probe-tests/`](native/android-kotlin/probe-tests/) — see the suite header for the run pattern (native verbs are CLI-side; a ProbeAgent host app holds the session while this app is foregrounded). + +```bash +cd native/android-kotlin +JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.alphawavesystems.probe_test_app_native/.MainActivity +``` + ## Running Tests ```bash diff --git a/native/android-kotlin/.gitignore b/native/android-kotlin/.gitignore new file mode 100644 index 0000000..e86dd35 --- /dev/null +++ b/native/android-kotlin/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.DS_Store diff --git a/native/android-kotlin/app/build.gradle.kts b/native/android-kotlin/app/build.gradle.kts new file mode 100644 index 0000000..43b6290 --- /dev/null +++ b/native/android-kotlin/app/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.alphawavesystems.probe_test_app_native" + compileSdk = 35 + buildToolsVersion = "35.0.0" + + defaultConfig { + // Distinct from the Flutter app (com.alphawavesystems.probe_test_app) + // so both twins can be installed on the same device. + applicationId = "com.alphawavesystems.probe_test_app_native" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2025.06.01") + implementation(composeBom) + + implementation("androidx.core:core-ktx:1.16.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") + implementation("androidx.navigation:navigation-compose:2.9.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1") + + // Real BiometricPrompt (fingerprint) — requires a FragmentActivity host. + implementation("androidx.biometric:biometric:1.1.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") +} diff --git a/native/android-kotlin/app/src/main/AndroidManifest.xml b/native/android-kotlin/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..921e6f4 --- /dev/null +++ b/native/android-kotlin/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/MainActivity.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/MainActivity.kt new file mode 100644 index 0000000..ab6a6e4 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/MainActivity.kt @@ -0,0 +1,49 @@ +package com.alphawavesystems.probe_test_app_native + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.fragment.app.FragmentActivity +import com.alphawavesystems.probe_test_app_native.navigation.AppNavHost + +/// Native Android (Jetpack Compose) twin of the Flutter probe test app. +/// +/// Extends [FragmentActivity] (not plain ComponentActivity) because +/// androidx.biometric's BiometricPrompt requires a FragmentActivity host. +class MainActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ProbeTestAppNative() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ProbeTestAppNative() { + // Mirror the Flutter app's deep-purple Material 3 light theme. + MaterialTheme( + colorScheme = lightColorScheme(primary = Color(0xFF6750A4)), + ) { + Surface( + modifier = Modifier + .fillMaxSize() + // Surfaces every Modifier.testTag(...) in the tree as a + // uiautomator resource-id so flutter-probe's native verbs + // (`tap native`, `see native`, `type native`) can match by id. + .semantics { testTagsAsResourceId = true }, + ) { + AppNavHost() + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/AuthRepository.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/AuthRepository.kt new file mode 100644 index 0000000..e35ed37 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/AuthRepository.kt @@ -0,0 +1,32 @@ +package com.alphawavesystems.probe_test_app_native.data + +import kotlinx.coroutines.delay + +data class User( + val id: Int, + val email: String, + val name: String, +) + +class AuthFailure(message: String) : Exception(message) + +/// Mirrors the Flutter app's AuthRemoteDatasource: simulated remote login +/// with a 1 second network delay and two hardcoded credential pairs. +object AuthRepository { + /// Accepted credentials: + /// - test@test.com / password -> Test User + /// - admin@test.com / admin123 -> Admin User + /// + /// Throws [AuthFailure] for anything else. + suspend fun login(email: String, password: String): User { + delay(1000) + + if (email == "test@test.com" && password == "password") { + return User(id = 1, email = "test@test.com", name = "Test User") + } + if (email == "admin@test.com" && password == "admin123") { + return User(id = 2, email = "admin@test.com", name = "Admin User") + } + throw AuthFailure("Invalid email or password") + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/AuthViewModel.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/AuthViewModel.kt new file mode 100644 index 0000000..ba48c05 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/AuthViewModel.kt @@ -0,0 +1,50 @@ +package com.alphawavesystems.probe_test_app_native.data + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's AuthProvider (ChangeNotifier). +class AuthViewModel : ViewModel() { + var currentUser by mutableStateOf(null) + private set + var isLoading by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + + val isAuthenticated: Boolean get() = currentUser != null + + fun login(email: String, password: String, onSuccess: () -> Unit) { + isLoading = true + error = null + viewModelScope.launch { + try { + currentUser = AuthRepository.login(email.trim(), password) + isLoading = false + onSuccess() + } catch (e: AuthFailure) { + error = e.message + isLoading = false + } catch (e: Exception) { + error = "An unexpected error occurred" + isLoading = false + } + } + } + + /// Marks the user as authenticated after a successful biometric capture. + /// Bypasses email/password since the OS has already proven identity. + fun loginWithBiometrics() { + currentUser = User(id = -1, email = "biometric@local", name = "Biometric User") + error = null + } + + fun logout() { + currentUser = null + error = null + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/ItemsViewModel.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/ItemsViewModel.kt new file mode 100644 index 0000000..1ee5adf --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/ItemsViewModel.kt @@ -0,0 +1,43 @@ +package com.alphawavesystems.probe_test_app_native.data + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel + +data class Item( + val id: Int, + val title: String, + val description: String, +) + +/// Twin of the Flutter app's ItemProvider: 50 generated items, live +/// case-insensitive title search, and delete (driven by swipe-to-dismiss). +class ItemsViewModel : ViewModel() { + private val allItems = (0 until 50) + .map { Item(id = it, title = "Item $it", description = "Description for item $it") } + .toMutableList() + + var searchQuery by mutableStateOf("") + private set + var items by mutableStateOf>(allItems.toList()) + private set + + fun search(query: String) { + searchQuery = query + applySearch() + } + + fun deleteItem(id: Int) { + allItems.removeAll { it.id == id } + applySearch() + } + + private fun applySearch() { + items = if (searchQuery.isEmpty()) { + allItems.toList() + } else { + allItems.filter { it.title.contains(searchQuery, ignoreCase = true) } + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/SettingsViewModel.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/SettingsViewModel.kt new file mode 100644 index 0000000..69d42ba --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/data/SettingsViewModel.kt @@ -0,0 +1,30 @@ +package com.alphawavesystems.probe_test_app_native.data + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel + +/// Twin of the Flutter app's SettingsProvider + AppPreferences entity. +/// Same defaults: darkMode=false, notifications=true, terms=false, English. +class SettingsViewModel : ViewModel() { + var darkMode by mutableStateOf(false) + private set + var notifications by mutableStateOf(true) + private set + var termsAccepted by mutableStateOf(false) + private set + var language by mutableStateOf("English") + private set + + val languages = listOf("English", "Spanish", "French", "German", "Japanese") + + fun toggleDarkMode(value: Boolean) { darkMode = value } + fun toggleNotifications(value: Boolean) { notifications = value } + fun toggleTermsAccepted(value: Boolean) { termsAccepted = value } + fun selectLanguage(value: String) { language = value } + + /// In-memory persistence, matching the Flutter twin's + /// SettingsRepositoryImpl (which also keeps preferences in memory). + fun savePreferences() { /* no-op: state is already the source of truth */ } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/navigation/AppNavHost.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/navigation/AppNavHost.kt new file mode 100644 index 0000000..03ae9bc --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/navigation/AppNavHost.kt @@ -0,0 +1,64 @@ +package com.alphawavesystems.probe_test_app_native.navigation + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.alphawavesystems.probe_test_app_native.data.AuthViewModel +import com.alphawavesystems.probe_test_app_native.data.ItemsViewModel +import com.alphawavesystems.probe_test_app_native.data.SettingsViewModel +import com.alphawavesystems.probe_test_app_native.screens.ApiScreen +import com.alphawavesystems.probe_test_app_native.screens.BiometricLoginScreen +import com.alphawavesystems.probe_test_app_native.screens.DashboardScreen +import com.alphawavesystems.probe_test_app_native.screens.DeviceScreen +import com.alphawavesystems.probe_test_app_native.screens.DynamicScreen +import com.alphawavesystems.probe_test_app_native.screens.GesturesScreen +import com.alphawavesystems.probe_test_app_native.screens.HomeScreen +import com.alphawavesystems.probe_test_app_native.screens.ItemListScreen +import com.alphawavesystems.probe_test_app_native.screens.LoginScreen +import com.alphawavesystems.probe_test_app_native.screens.SettingsScreen +import com.alphawavesystems.probe_test_app_native.screens.SignalScreen +import com.alphawavesystems.probe_test_app_native.screens.VisualScreen + +/// Route names mirror the Flutter app's Routes class (minus the leading '/'). +object Routes { + const val HOME = "home" + const val LOGIN = "login" + const val BIOMETRIC = "biometric" + const val DASHBOARD = "dashboard" + const val SETTINGS = "settings" + const val ITEMS = "items" + const val GESTURES = "gestures" + const val API = "api" + const val DEVICE = "device" + const val VISUAL = "visual" + const val DYNAMIC = "dynamic" + const val SIGNAL = "signal" +} + +@Composable +fun AppNavHost() { + val navController = rememberNavController() + + // Activity-scoped view models — shared across destinations, surviving + // navigation, exactly like the Flutter app's app-level Providers. + val authViewModel: AuthViewModel = viewModel() + val itemsViewModel: ItemsViewModel = viewModel() + val settingsViewModel: SettingsViewModel = viewModel() + + NavHost(navController = navController, startDestination = Routes.HOME) { + composable(Routes.HOME) { HomeScreen(navController) } + composable(Routes.LOGIN) { LoginScreen(navController, authViewModel) } + composable(Routes.BIOMETRIC) { BiometricLoginScreen(navController, authViewModel) } + composable(Routes.DASHBOARD) { DashboardScreen(navController, authViewModel) } + composable(Routes.SETTINGS) { SettingsScreen(navController, settingsViewModel) } + composable(Routes.ITEMS) { ItemListScreen(navController, itemsViewModel) } + composable(Routes.GESTURES) { GesturesScreen(navController) } + composable(Routes.API) { ApiScreen(navController) } + composable(Routes.DEVICE) { DeviceScreen(navController) } + composable(Routes.VISUAL) { VisualScreen(navController) } + composable(Routes.DYNAMIC) { DynamicScreen(navController) } + composable(Routes.SIGNAL) { SignalScreen(navController) } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/ApiScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/ApiScreen.kt new file mode 100644 index 0000000..0cafee7 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/ApiScreen.kt @@ -0,0 +1,120 @@ +package com.alphawavesystems.probe_test_app_native.screens + +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.Button +import androidx.compose.material3.CircularProgressIndicator +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's API demo page (route /api): simulated fetch and +/// post with the same latencies and result texts. +@Composable +fun ApiScreen(navController: NavController) { + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var users by remember { mutableStateOf>(emptyList()) } + var postResult by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + ScreenScaffold("API Demo", navController) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Button( + onClick = { + scope.launch { + loading = true + error = null + delay(1000) + users = listOf("Leanne Graham", "Ervin Howell", "Clementine Bauch") + loading = false + } + }, + enabled = !loading, + modifier = Modifier.fillMaxWidth().testTag("fetch_users_button"), + ) { + Text("Fetch Users") + } + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + scope.launch { + loading = true + error = null + delay(500) + postResult = "Post created with id: 101" + loading = false + } + }, + enabled = !loading, + modifier = Modifier.fillMaxWidth().testTag("create_post_button"), + ) { + Text("Create Post") + } + Spacer(Modifier.height(16.dp)) + if (loading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("api_loading"), + ) + } + error?.let { + Text( + it, + modifier = Modifier.testTag("api_error"), + color = MaterialTheme.colorScheme.error, + fontSize = 16.sp, + ) + } + if (users.isNotEmpty()) { + Text("Users:", fontWeight = FontWeight.Bold, fontSize = 16.sp) + users.forEachIndexed { i, user -> + Text( + user, + modifier = Modifier + .padding(vertical = 4.dp) + .testTag("user_$i"), + ) + } + } + postResult?.let { + Text( + it, + modifier = Modifier + .padding(top = 16.dp) + .testTag("post_result"), + fontSize = 16.sp, + color = Color(0xFF4CAF50), + ) + } + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/BiometricLoginScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/BiometricLoginScreen.kt new file mode 100644 index 0000000..ce9638f --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/BiometricLoginScreen.kt @@ -0,0 +1,165 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.border +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavController +import com.alphawavesystems.probe_test_app_native.data.AuthViewModel +import com.alphawavesystems.probe_test_app_native.navigation.Routes + +/// Twin of the Flutter app's BiometricLoginPage, backed by the real Android +/// BiometricPrompt (fingerprint on the emulator). +/// +/// Driven by flutter-probe's biometric verbs: +/// - `enroll biometric` — no-op on Android (enroll a fingerprint once in +/// emulator Settings; `adb emu finger touch` needs it) +/// - `biometric match` — `adb emu finger touch 1` -> onAuthenticationSucceeded +/// - `biometric no match` — `adb emu finger touch 9999` -> onAuthenticationFailed +/// +/// Like the Flutter twin, the flow is single-shot: the first no-match cancels +/// the prompt and shows the "Authentication failed" banner instead of letting +/// the OS dialog retry forever — keeping no-match deterministic to assert. +@Composable +fun BiometricLoginScreen(navController: NavController, auth: AuthViewModel) { + val context = LocalContext.current + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + fun onSuccess() { + auth.loginWithBiometrics() + navController.navigate(Routes.DASHBOARD) { + popUpTo(Routes.HOME) + } + } + + fun startBiometricAuth() { + busy = true + error = null + + val activity = context as? FragmentActivity + val canAuth = BiometricManager.from(context).canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_WEAK, + ) + if (activity == null || canAuth != BiometricManager.BIOMETRIC_SUCCESS) { + // Mirrors the Flutter twin: any platform-level problem (no + // hardware, nothing enrolled) is treated as an auth failure. + busy = false + error = "Authentication failed" + return + } + + lateinit var prompt: BiometricPrompt + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + busy = false + onSuccess() + } + + override fun onAuthenticationFailed() { + // Single-shot: first no-match cancels the OS dialog. + prompt.cancelAuthentication() + busy = false + error = "Authentication failed" + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + busy = false + if (error == null) error = "Authentication failed" + } + } + + prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(context), callback) + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle("Biometric Login") + .setSubtitle("Sign in to your account") + .setNegativeButtonText("Cancel") + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK) + .build(), + ) + } + + ScreenScaffold("Biometric Login", navController) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + ) { + Spacer(Modifier.height(48.dp)) + Icon( + Icons.Filled.Face, + contentDescription = null, + modifier = Modifier.size(96.dp).align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.height(16.dp)) + Text( + "Use your face or fingerprint to sign in.", + modifier = Modifier.fillMaxWidth().testTag("biometric_prompt_intro"), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + Button( + onClick = { startBiometricAuth() }, + enabled = !busy, + modifier = Modifier.fillMaxWidth().testTag("sign_in_with_face_id"), + ) { + Icon(Icons.Filled.Face, contentDescription = null) + Spacer(Modifier.size(8.dp)) + Text("Sign in with Face ID") + } + if (busy) { + Spacer(Modifier.height(24.dp)) + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(8.dp) + .testTag("biometric_progress"), + ) + } + error?.let { message -> + Spacer(Modifier.height(24.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFFFEBEE), RoundedCornerShape(8.dp)) + .border(1.dp, Color(0xFFEF9A9A), RoundedCornerShape(8.dp)) + .padding(12.dp) + .testTag("biometric_error_banner"), + ) { + Text(message, color = Color(0xFFC62828)) + } + } + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/Common.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/Common.kt new file mode 100644 index 0000000..45e767f --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/Common.kt @@ -0,0 +1,58 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavController + +/// Shared scaffold used by every sub-screen: top bar with the screen title +/// (the text every probe test asserts on) and a back arrow, mirroring the +/// Flutter app's AppBar behavior. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScreenScaffold( + title: String, + navController: NavController, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + actions: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + showBack: Boolean = true, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + if (showBack) { + // Tagged because the native verb family has no generic + // `go back`; suites navigate home via this button. + IconButton( + onClick = { navController.popBackStack() }, + modifier = Modifier.testTag("back_button"), + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + }, + actions = { actions() }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = floatingActionButton, + ) { padding -> + content(padding) + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DashboardScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DashboardScreen.kt new file mode 100644 index 0000000..f06d417 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DashboardScreen.kt @@ -0,0 +1,159 @@ +package com.alphawavesystems.probe_test_app_native.screens + +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.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.alphawavesystems.probe_test_app_native.data.AuthViewModel +import com.alphawavesystems.probe_test_app_native.navigation.Routes +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's DashboardPage. +@Composable +fun DashboardScreen(navController: NavController, auth: AuthViewModel) { + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val userName = auth.currentUser?.name ?: "Guest" + + ScreenScaffold( + title = "Dashboard", + navController = navController, + snackbarHostState = snackbarHostState, + actions = { + IconButton( + onClick = { + scope.launch { snackbarHostState.showSnackbar("Refreshed") } + }, + modifier = Modifier.testTag("refresh_button"), + ) { + Icon(Icons.Filled.Refresh, contentDescription = "Refresh") + } + IconButton( + onClick = { + auth.logout() + navController.navigate(Routes.HOME) { + popUpTo(Routes.HOME) { inclusive = true } + } + }, + modifier = Modifier.testTag("logout_button"), + ) { + Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Logout") + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + ) { + item { + Card( + modifier = Modifier.fillMaxWidth().testTag("welcome_banner"), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column(Modifier.padding(20.dp)) { + Text( + "Welcome, $userName", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Here is your dashboard overview", + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + } + } + Spacer(Modifier.height(16.dp)) + Row { + StatCard("stat_card_1", "Tests Run", "42", Icons.Filled.CheckCircle, Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + StatCard("stat_card_2", "Passed", "38", Icons.Filled.ThumbUp, Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + StatCard("stat_card_3", "Failed", "4", Icons.Filled.Warning, Modifier.weight(1f)) + } + Spacer(Modifier.height(24.dp)) + Text("Recent Items", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + } + items(10) { index -> + ListItem( + modifier = Modifier + .testTag("item_$index") + .clickable { + scope.launch { + snackbarHostState.showSnackbar("Tapped item ${index + 1}") + } + }, + leadingContent = { Text("${index + 1}") }, + headlineContent = { Text("Item ${index + 1}") }, + supportingContent = { Text("Description for item ${index + 1}") }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + ) + } + } + } +} + +@Composable +private fun StatCard( + tag: String, + title: String, + value: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Card(modifier = modifier.testTag(tag)) { + Column( + Modifier.fillMaxWidth().padding(12.dp), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Text( + value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Text( + title, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DeviceScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DeviceScreen.kt new file mode 100644 index 0000000..ed7e87d --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DeviceScreen.kt @@ -0,0 +1,153 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import android.content.Intent +import androidx.core.net.toUri +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's DevicePage. Camera/location "permissions" are +/// simulated state transitions exactly like the Flutter twin (which never +/// calls the real permission APIs either) so probe assertions stay +/// deterministic; the browser button fires a real ACTION_VIEW intent and the +/// clipboard section reads the real system clipboard. +@Composable +fun DeviceScreen(navController: NavController) { + var cameraStatus by remember { mutableStateOf("Not requested") } + var locationStatus by remember { mutableStateOf("Not requested") } + var gpsDisplay by remember { mutableStateOf("Location: --") } + var copyText by remember { mutableStateOf("Copy this text") } + var pastedText by remember { mutableStateOf("") } + + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + ScreenScaffold("Device", navController, snackbarHostState) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text("Permissions", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { cameraStatus = "Granted" }, + modifier = Modifier.testTag("request_camera"), + ) { + Icon(Icons.Filled.Person, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Request Camera") + } + Spacer(Modifier.height(4.dp)) + Text("Camera: $cameraStatus", modifier = Modifier.testTag("camera_status")) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { + locationStatus = "Granted" + gpsDisplay = "Location: 37.7749, -122.4194" + }, + modifier = Modifier.testTag("request_location"), + ) { + Icon(Icons.Filled.LocationOn, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Request Location") + } + Spacer(Modifier.height(4.dp)) + Text("Location: $locationStatus", modifier = Modifier.testTag("location_status")) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { + scope.launch { + snackbarHostState.showSnackbar("Notification permission requested") + } + }, + modifier = Modifier.testTag("request_notifications"), + ) { + Icon(Icons.Filled.Notifications, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Request Notifications") + } + Spacer(Modifier.height(24.dp)) + + Text("GPS", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + Text(gpsDisplay, modifier = Modifier.testTag("gps_display"), fontSize = 16.sp) + Spacer(Modifier.height(24.dp)) + + Text("Browser", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, "https://www.google.com".toUri()), + ) + }, + modifier = Modifier.testTag("open_browser"), + ) { + Icon(Icons.Filled.Share, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Open Website") + } + Spacer(Modifier.height(24.dp)) + + Text("Clipboard", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = copyText, + onValueChange = { copyText = it }, + modifier = Modifier.fillMaxWidth().testTag("copy_text_field"), + label = { Text("Text to copy") }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + pastedText = clipboard.getText()?.text ?: "Nothing in clipboard" + }, + modifier = Modifier.testTag("paste_button"), + ) { + Icon(Icons.Filled.Send, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Paste") + } + Spacer(Modifier.height(8.dp)) + Text(pastedText, modifier = Modifier.testTag("pasted_text"), fontSize = 14.sp) + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DynamicScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DynamicScreen.kt new file mode 100644 index 0000000..43a9c09 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/DynamicScreen.kt @@ -0,0 +1,160 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import kotlin.random.Random +import kotlinx.coroutines.delay + +/// Twin of the Flutter app's DynamicPage: random A/B banner, 10s countdown, +/// 2s fade-in widget, error dialog, and a repeatable tap counter. +@Composable +fun DynamicScreen(navController: NavController) { + val showAbBanner = remember { Random.nextBoolean() } + var countdown by remember { mutableIntStateOf(10) } + var actionCount by remember { mutableIntStateOf(0) } + var showErrorDialog by remember { mutableStateOf(false) } + val fadeAlpha = remember { Animatable(0f) } + + LaunchedEffect(Unit) { + fadeAlpha.animateTo(1f, animationSpec = tween(durationMillis = 2000)) + } + LaunchedEffect(Unit) { + while (countdown > 0) { + delay(1000) + countdown-- + } + } + + if (showErrorDialog) { + AlertDialog( + modifier = Modifier.testTag("error_dialog"), + onDismissRequest = { showErrorDialog = false }, + title = { Text("Error") }, + text = { Text("Something went wrong!") }, + confirmButton = { + TextButton(onClick = { showErrorDialog = false }) { + Text("OK") + } + }, + ) + } + + ScreenScaffold("Dynamic", navController) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + if (showAbBanner) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .background(Color(0xFFFFC107), RoundedCornerShape(8.dp)) + .padding(16.dp) + .testTag("ab_banner"), + contentAlignment = Alignment.Center, + ) { + Text( + "Special Offer!", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } + } + + Text("Countdown", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + Text( + "$countdown", + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("countdown"), + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = if (countdown <= 3) Color.Red else Color.Black, + ) + Spacer(Modifier.height(24.dp)) + + Text("Fade Animation", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .graphicsLayer { alpha = fadeAlpha.value } + .background(Color(0xFF009688), RoundedCornerShape(8.dp)) + .testTag("fade_widget"), + contentAlignment = Alignment.Center, + ) { + Text("I faded in!", color = Color.White, fontSize = 18.sp) + } + Spacer(Modifier.height(24.dp)) + + Text("Error Handling", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { showErrorDialog = true }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFF44336), + contentColor = Color.White, + ), + modifier = Modifier.testTag("trigger_error"), + ) { + Text("Trigger Error") + } + Spacer(Modifier.height(24.dp)) + + Text("Repeatable Action", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { actionCount++ }, + modifier = Modifier.testTag("repeat_action"), + ) { + Text("Tap Me") + } + Spacer(Modifier.height(8.dp)) + Text( + "Tapped: $actionCount", + modifier = Modifier.testTag("action_count"), + fontSize = 16.sp, + ) + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/GesturesScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/GesturesScreen.kt new file mode 100644 index 0000000..585b7ce --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/GesturesScreen.kt @@ -0,0 +1,258 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +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.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import kotlin.math.roundToInt + +/// Twin of the Flutter app's GesturesPage: drag & drop, double tap, +/// long press (context menu), and a dismissible swipe card, all feeding a +/// shared gesture counter. +/// +/// Native-twin addition (not in the Flutter page): a single-"Tap" zone +/// (`tap_capture_area` / `tap_capture_count`). flutter-probe's native verb +/// family has no double-tap/long-press/swipe equivalents yet, so this gives +/// `tap native` a real gesture target whose recognized-gesture state can be +/// asserted. (Named `tap_capture_*` rather than `tap_*` because native +/// matching is substring-based and `tap_area` would collide with +/// `double_tap_area`.) +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GesturesScreen(navController: NavController) { + var gestureCount by remember { mutableIntStateOf(0) } + var tapCount by remember { mutableIntStateOf(0) } + var doubleTapCount by remember { mutableIntStateOf(0) } + var dragAccepted by remember { mutableStateOf(false) } + var swipeCardVisible by remember { mutableStateOf(true) } + var longPressMenuVisible by remember { mutableStateOf(false) } + + // Drag & drop bookkeeping. + var dragOffset by remember { mutableStateOf(Offset.Zero) } + var sourceBounds by remember { mutableStateOf(Rect.Zero) } + var targetBounds by remember { mutableStateOf(Rect.Zero) } + + ScreenScaffold("Gestures", navController) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text( + "Gesture Count: $gestureCount", + modifier = Modifier.testTag("gesture_count"), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(24.dp)) + + SectionLabel("Tap") + Box( + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .background(Color(0xFFB3E5FC)) + .testTag("tap_capture_area") + .pointerInput(Unit) { + detectTapGestures(onTap = { + tapCount++ + gestureCount++ + }) + }, + contentAlignment = Alignment.Center, + ) { + Text("Taps: $tapCount", fontSize = 16.sp, modifier = Modifier.testTag("tap_capture_count")) + } + Spacer(Modifier.height(24.dp)) + + SectionLabel("Drag & Drop") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceEvenly, + ) { + Box( + modifier = Modifier + .offset { IntOffset(dragOffset.x.roundToInt(), dragOffset.y.roundToInt()) } + .size(80.dp) + .background(Color(0xFF2196F3)) + .onGloballyPositioned { sourceBounds = it.boundsInRoot() } + .testTag("drag_source") + .pointerInput(Unit) { + detectDragGestures( + onDrag = { change, amount -> + change.consume() + dragOffset += amount + }, + onDragEnd = { + if (targetBounds.contains(sourceBounds.center)) { + dragAccepted = true + gestureCount++ + } + dragOffset = Offset.Zero + }, + onDragCancel = { dragOffset = Offset.Zero }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Text("Drag", color = Color.White) + } + Box( + modifier = Modifier + .size(80.dp) + .background(if (dragAccepted) Color(0xFF69F0AE) else Color(0xFF4CAF50)) + .onGloballyPositioned { targetBounds = it.boundsInRoot() } + .testTag("drag_target"), + contentAlignment = Alignment.Center, + ) { + Text(if (dragAccepted) "Done!" else "Drop", color = Color.White) + } + } + Spacer(Modifier.height(24.dp)) + + SectionLabel("Double Tap") + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .background(Color(0xFFFFE0B2)) + .testTag("double_tap_area") + .pointerInput(Unit) { + detectTapGestures(onDoubleTap = { + doubleTapCount++ + gestureCount++ + }) + }, + contentAlignment = Alignment.Center, + ) { + Text("Double taps: $doubleTapCount", fontSize = 16.sp) + } + Spacer(Modifier.height(24.dp)) + + SectionLabel("Long Press") + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .background(Color(0xFFE1BEE7)) + .testTag("long_press_area") + .combinedClickable( + onClick = {}, + onLongClick = { + gestureCount++ + longPressMenuVisible = true + }, + ), + contentAlignment = Alignment.Center, + ) { + Text("Long press me", fontSize = 16.sp) + DropdownMenu( + expanded = longPressMenuVisible, + onDismissRequest = { longPressMenuVisible = false }, + ) { + DropdownMenuItem( + text = { Text("Copy") }, + onClick = { longPressMenuVisible = false }, + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { longPressMenuVisible = false }, + ) + } + } + Spacer(Modifier.height(24.dp)) + + SectionLabel("Swipe Card") + if (swipeCardVisible) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value != SwipeToDismissBoxValue.Settled) { + swipeCardVisible = false + gestureCount++ + true + } else { + false + } + }, + ) + SwipeToDismissBox( + state = dismissState, + modifier = Modifier.testTag("swipe_card"), + backgroundContent = { + Box( + Modifier + .fillMaxSize() + .background(Color.Red) + .padding(end = 16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White) + } + }, + ) { + Card { + ListItem( + leadingContent = { Icon(Icons.Filled.Share, contentDescription = null) }, + headlineContent = { Text("Swipe me to dismiss") }, + ) + } + } + } else { + Text("Card dismissed!") + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text(text, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/HomeScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/HomeScreen.kt new file mode 100644 index 0000000..70dc9dc --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/HomeScreen.kt @@ -0,0 +1,182 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.alphawavesystems.probe_test_app_native.navigation.Routes + +/// Twin of the Flutter app's HomePage: 3-tab shell (Home / Tests / About) +/// with the Home tab hosting the navigation list to every other screen. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen(navController: NavController) { + var currentTab by rememberSaveable { mutableIntStateOf(0) } + + Scaffold( + topBar = { TopAppBar(title = { Text("FlutterProbe Test App") }) }, + bottomBar = { + NavigationBar { + NavigationBarItem( + modifier = Modifier.testTag("tab_home"), + selected = currentTab == 0, + onClick = { currentTab = 0 }, + icon = { + Icon( + Icons.Filled.Home, + contentDescription = null, + modifier = Modifier.testTag("tab_home_icon"), + ) + }, + label = { Text("Home") }, + ) + NavigationBarItem( + modifier = Modifier.testTag("tab_tests"), + selected = currentTab == 1, + onClick = { currentTab = 1 }, + icon = { + Icon( + Icons.Filled.Star, + contentDescription = null, + modifier = Modifier.testTag("tab_tests_icon"), + ) + }, + label = { Text("Tests") }, + ) + NavigationBarItem( + modifier = Modifier.testTag("tab_about"), + selected = currentTab == 2, + onClick = { currentTab = 2 }, + icon = { + Icon( + Icons.Filled.Info, + contentDescription = null, + modifier = Modifier.testTag("tab_about_icon"), + ) + }, + label = { Text("About") }, + ) + } + }, + ) { padding -> + Box(Modifier.padding(padding).fillMaxSize()) { + when (currentTab) { + 1 -> Text( + "Test Suites", + modifier = Modifier.align(Alignment.Center).testTag("tests_tab_content"), + fontSize = 18.sp, + ) + 2 -> Text( + "About FlutterProbe Test App", + modifier = Modifier.align(Alignment.Center).testTag("about_tab_content"), + fontSize = 18.sp, + ) + else -> HomeTabContent(navController) + } + } + } +} + +@Composable +private fun HomeTabContent(navController: NavController) { + Column(Modifier.fillMaxSize()) { + Text( + "Welcome to the FlutterProbe Test App", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .testTag("welcome_text"), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + LazyColumn(Modifier.weight(1f)) { + item { NavTile("Login", Icons.Filled.Lock, Routes.LOGIN, "nav_login", navController) } + item { NavTile("Biometric Login", Icons.Filled.Face, Routes.BIOMETRIC, "nav_biometric", navController) } + item { NavTile("Signal Demo", Icons.Filled.Notifications, Routes.SIGNAL, "nav_signal", navController) } + item { NavTile("Dashboard", Icons.Filled.CheckCircle, Routes.DASHBOARD, "nav_dashboard", navController) } + item { NavTile("Settings", Icons.Filled.Settings, Routes.SETTINGS, "nav_settings", navController) } + item { NavTile("Items", Icons.AutoMirrored.Filled.List, Routes.ITEMS, "nav_items", navController) } + item { NavTile("Gestures", Icons.Filled.ThumbUp, Routes.GESTURES, "nav_gestures", navController) } + item { NavTile("API Tests", Icons.Filled.Person, Routes.API, "nav_api", navController) } + item { NavTile("Device", Icons.Filled.LocationOn, Routes.DEVICE, "nav_device", navController) } + item { NavTile("Visual", Icons.Filled.AccountCircle, Routes.VISUAL, "nav_visual", navController) } + item { NavTile("Dynamic", Icons.Filled.Star, Routes.DYNAMIC, "nav_dynamic", navController) } + } + Text( + "Version 1.0", + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .testTag("version_text"), + color = Color.Gray, + fontSize = 12.sp, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun NavTile( + title: String, + icon: ImageVector, + route: String, + tag: String, + navController: NavController, +) { + ListItem( + modifier = Modifier + .testTag(tag) + .clickable { navController.navigate(route) }, + leadingContent = { Icon(icon, contentDescription = null) }, + headlineContent = { Text(title) }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/ItemListScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/ItemListScreen.kt new file mode 100644 index 0000000..8c0416b --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/ItemListScreen.kt @@ -0,0 +1,135 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.alphawavesystems.probe_test_app_native.data.Item +import com.alphawavesystems.probe_test_app_native.data.ItemsViewModel +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's ItemListPage: searchable 50-item list with +/// swipe-to-dismiss delete, empty state, and an add FAB. +@Composable +fun ItemListScreen(navController: NavController, itemsViewModel: ItemsViewModel) { + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + ScreenScaffold( + title = "Items", + navController = navController, + snackbarHostState = snackbarHostState, + floatingActionButton = { + FloatingActionButton( + onClick = { + scope.launch { snackbarHostState.showSnackbar("Add item tapped") } + }, + modifier = Modifier.testTag("fab_add"), + ) { + Icon(Icons.Filled.Add, contentDescription = "Add") + } + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + OutlinedTextField( + value = itemsViewModel.searchQuery, + onValueChange = itemsViewModel::search, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .testTag("search_field"), + placeholder = { Text("Search items...") }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + singleLine = true, + ) + if (itemsViewModel.items.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "No items found", + modifier = Modifier.testTag("empty_state"), + style = MaterialTheme.typography.titleMedium, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag("scrollable_list"), + ) { + items(itemsViewModel.items, key = { it.id }) { item -> + DismissibleItemRow( + item = item, + index = itemsViewModel.items.indexOf(item), + onDelete = { itemsViewModel.deleteItem(item.id) }, + ) + } + } + } + } + } +} + +@Composable +private fun DismissibleItemRow(item: Item, index: Int, onDelete: () -> Unit) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + onDelete() + true + } else { + false + } + }, + ) + + SwipeToDismissBox( + state = dismissState, + modifier = Modifier.testTag("dismissible_${item.id}"), + enableDismissFromStartToEnd = false, + backgroundContent = { + Box( + Modifier + .fillMaxSize() + .background(Color.Red) + .padding(end = 16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White) + } + }, + ) { + ListItem( + modifier = Modifier.testTag("list_item_$index"), + leadingContent = { Text("${item.id}") }, + headlineContent = { Text(item.title) }, + supportingContent = { Text(item.description) }, + ) + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/LoginScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/LoginScreen.kt new file mode 100644 index 0000000..fc27a13 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/LoginScreen.kt @@ -0,0 +1,132 @@ +package com.alphawavesystems.probe_test_app_native.screens + +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.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.alphawavesystems.probe_test_app_native.data.AuthViewModel +import com.alphawavesystems.probe_test_app_native.navigation.Routes +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's LoginPage. +@Composable +fun LoginScreen(navController: NavController, auth: AuthViewModel) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + ScreenScaffold("Login", navController, snackbarHostState) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + ) { + Spacer(Modifier.height(48.dp)) + Icon( + Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(80.dp).align(Alignment.CenterHorizontally), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(32.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + modifier = Modifier.fillMaxWidth().testTag("email_field"), + label = { Text("Email") }, + placeholder = { Text("Enter your email") }, + leadingIcon = { Icon(Icons.Filled.Email, contentDescription = null) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + modifier = Modifier.fillMaxWidth().testTag("password_field"), + label = { Text("Password") }, + placeholder = { Text("Enter your password") }, + leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + auth.error?.let { error -> + Text( + error, + modifier = Modifier.padding(bottom = 8.dp).testTag("error_message"), + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp, + ) + } + Spacer(Modifier.height(16.dp)) + if (auth.isLoading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("loading_indicator"), + ) + } else { + Button( + onClick = { + auth.login(email, password) { + navController.navigate(Routes.DASHBOARD) { + popUpTo(Routes.HOME) + } + } + }, + modifier = Modifier.fillMaxWidth().testTag("sign_in_button"), + ) { + Text("Sign In", fontSize = 16.sp) + } + } + Spacer(Modifier.height(12.dp)) + TextButton( + onClick = { + scope.launch { + snackbarHostState.showSnackbar("Password reset not implemented") + } + }, + modifier = Modifier.align(Alignment.CenterHorizontally).testTag("forgot_password"), + ) { + Text("Forgot Password?") + } + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/SettingsScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/SettingsScreen.kt new file mode 100644 index 0000000..b06951d --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/SettingsScreen.kt @@ -0,0 +1,152 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.alphawavesystems.probe_test_app_native.data.SettingsViewModel +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's SettingsPage. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(navController: NavController, settings: SettingsViewModel) { + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + var languageMenuExpanded by remember { mutableStateOf(false) } + + ScreenScaffold("Settings", navController, snackbarHostState) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Dark Mode", Modifier.weight(1f)) + Switch( + checked = settings.darkMode, + onCheckedChange = settings::toggleDarkMode, + modifier = Modifier.testTag("dark_mode_toggle"), + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Notifications", Modifier.weight(1f)) + Switch( + checked = settings.notifications, + onCheckedChange = settings::toggleNotifications, + modifier = Modifier.testTag("notifications_toggle"), + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Agree to Terms", Modifier.weight(1f)) + Checkbox( + checked = settings.termsAccepted, + onCheckedChange = settings::toggleTermsAccepted, + modifier = Modifier.testTag("terms_checkbox"), + ) + } + Spacer(Modifier.height(16.dp)) + ExposedDropdownMenuBox( + expanded = languageMenuExpanded, + onExpandedChange = { languageMenuExpanded = it }, + ) { + OutlinedTextField( + value = settings.language, + onValueChange = {}, + readOnly = true, + label = { Text("Language") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = languageMenuExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .testTag("language_dropdown"), + ) + ExposedDropdownMenu( + expanded = languageMenuExpanded, + onDismissRequest = { languageMenuExpanded = false }, + ) { + settings.languages.forEach { lang -> + DropdownMenuItem( + text = { Text(lang) }, + onClick = { + settings.selectLanguage(lang) + languageMenuExpanded = false + }, + ) + } + } + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = { + settings.savePreferences() + scope.launch { snackbarHostState.showSnackbar("Settings saved") } + }, + modifier = Modifier.fillMaxWidth().testTag("save_button"), + ) { + Text("Save") + } + Spacer(Modifier.height(12.dp)) + Button( + onClick = {}, + enabled = false, + colors = ButtonDefaults.buttonColors(disabledContainerColor = Color.Gray), + modifier = Modifier.fillMaxWidth().testTag("delete_button"), + ) { + Text("Delete Account") + } + Spacer(Modifier.height(24.dp)) + Text( + "Build: 1.0.42+dev", + modifier = Modifier.fillMaxWidth().testTag("build_info"), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/SignalScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/SignalScreen.kt new file mode 100644 index 0000000..4b1f9c2 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/SignalScreen.kt @@ -0,0 +1,103 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/// Twin of the Flutter app's SignalDemoPage. +/// +/// Deliberate deviation: the Flutter twin's PROBE_AGENT builds block on +/// `awaitSignal(...)` so the CLI's `deliver signal` verb can resolve them. +/// That mechanism lives in the Dart ProbeAgent and has no native-side +/// counterpart, so this twin always uses the "real implementation" path the +/// Flutter app uses outside probe mode: a 1 second async operation that then +/// lands on the same status texts (`Notifications enabled`, `Payment +/// confirmed`, `Opened: https://example.com`). +@Composable +fun SignalScreen(navController: NavController) { + var status by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + fun run(pendingStatus: String, finalStatus: String) { + scope.launch { + busy = true + status = pendingStatus + delay(1000) + busy = false + status = finalStatus + } + } + + ScreenScaffold("Signal Demo", navController) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + ) { + Spacer(Modifier.height(24.dp)) + Button( + onClick = { run("Waiting for permission…", "Notifications enabled") }, + enabled = !busy, + modifier = Modifier.fillMaxWidth().testTag("request_push_permission"), + ) { + Text("Request Push Permission") + } + Spacer(Modifier.height(12.dp)) + Button( + onClick = { run("Payment in progress…", "Payment confirmed") }, + enabled = !busy, + modifier = Modifier.fillMaxWidth().testTag("start_payment"), + ) { + Text("Start Payment") + } + Spacer(Modifier.height(12.dp)) + Button( + onClick = { run("Opening link…", "Opened: https://example.com") }, + enabled = !busy, + modifier = Modifier.fillMaxWidth().testTag("open_deep_link"), + ) { + Text("Open Deep Link") + } + if (busy) { + Spacer(Modifier.height(24.dp)) + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("signal_progress"), + ) + } + if (status.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + Text( + status, + modifier = Modifier.fillMaxWidth().testTag("signal_status"), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } +} diff --git a/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/VisualScreen.kt b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/VisualScreen.kt new file mode 100644 index 0000000..8b6d687 --- /dev/null +++ b/native/android-kotlin/app/src/main/java/com/alphawavesystems/probe_test_app_native/screens/VisualScreen.kt @@ -0,0 +1,160 @@ +package com.alphawavesystems.probe_test_app_native.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController + +/// Twin of the Flutter app's VisualPage: static header, 2x2 color grid, +/// tap counter, image placeholder, and a typography sample. +@Composable +fun VisualScreen(navController: NavController) { + var counter by remember { mutableIntStateOf(0) } + + ScreenScaffold("Visual", navController) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF673AB7), RoundedCornerShape(12.dp)) + .padding(24.dp) + .testTag("visual_header"), + contentAlignment = Alignment.Center, + ) { + Text( + "Visual Test", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(24.dp)) + + Column( + modifier = Modifier.fillMaxWidth().testTag("color_grid"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ColorCell("Red", Color(0xFFF44336), Color.White, Modifier.weight(1f)) + ColorCell("Green", Color(0xFF4CAF50), Color.White, Modifier.weight(1f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ColorCell("Blue", Color(0xFF2196F3), Color.White, Modifier.weight(1f)) + ColorCell("Yellow", Color(0xFFFFEB3B), Color.Black, Modifier.weight(1f)) + } + } + Spacer(Modifier.height(24.dp)) + + Text( + "Count: $counter", + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("visual_counter"), + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { counter++ }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("increment_button"), + ) { + Text("Increment") + } + Spacer(Modifier.height(24.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .background(Color(0xFFE0E0E0), RoundedCornerShape(8.dp)) + .border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) + .testTag("static_image"), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Filled.Star, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.height(48.dp), + ) + Spacer(Modifier.height(8.dp)) + Text("Image Placeholder", color = Color.Gray) + } + Spacer(Modifier.height(24.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, Color(0xFFE0E0E0), RoundedCornerShape(8.dp)) + .padding(16.dp) + .testTag("typography_sample"), + ) { + Text("Heading 1", fontSize = 32.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text("Heading 2", fontSize = 24.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Text("Body text at 16px", fontSize = 16.sp) + Spacer(Modifier.height(8.dp)) + Text("Caption text at 12px", fontSize = 12.sp) + Spacer(Modifier.height(8.dp)) + Text("Small text at 10px", fontSize = 10.sp) + } + } + } +} + +@Composable +private fun ColorCell( + label: String, + background: Color, + textColor: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .aspectRatio(1.6f) + .background(background, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text(label, color = textColor, fontSize = 18.sp) + } +} diff --git a/native/android-kotlin/build.gradle.kts b/native/android-kotlin/build.gradle.kts new file mode 100644 index 0000000..00874d7 --- /dev/null +++ b/native/android-kotlin/build.gradle.kts @@ -0,0 +1,4 @@ +// Root build file — plugin versions are declared in settings.gradle.kts. +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/native/android-kotlin/gradle.properties b/native/android-kotlin/gradle.properties new file mode 100644 index 0000000..c751e06 --- /dev/null +++ b/native/android-kotlin/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/native/android-kotlin/gradle/wrapper/gradle-wrapper.jar b/native/android-kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000..13372ae Binary files /dev/null and b/native/android-kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/native/android-kotlin/gradle/wrapper/gradle-wrapper.properties b/native/android-kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/native/android-kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/native/android-kotlin/gradlew b/native/android-kotlin/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/native/android-kotlin/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/native/android-kotlin/gradlew.bat b/native/android-kotlin/gradlew.bat new file mode 100755 index 0000000..aec9973 --- /dev/null +++ b/native/android-kotlin/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/native/android-kotlin/probe-tests/native_twin_suite.probe b/native/android-kotlin/probe-tests/native_twin_suite.probe new file mode 100644 index 0000000..55ae081 --- /dev/null +++ b/native/android-kotlin/probe-tests/native_twin_suite.probe @@ -0,0 +1,213 @@ +# Native-verb suite against the Kotlin/Compose twin of the Flutter probe +# test app (native/android-kotlin, applicationId +# com.alphawavesystems.probe_test_app_native). +# +# Run pattern (same as flutter-probe/native-test-apps/android): +# +# 1. A ProbeAgent host app (e.g. the Flutter twin in mobile/) is running +# and connected — probe's session lives there; the native verb family +# is CLI-side (uiautomator via adb) and never touches the Dart agent. +# 2. Bring this native app to the foreground BEFORE the run, at the Home +# screen: +# adb shell am start -n com.alphawavesystems.probe_test_app_native/.MainActivity +# 3. No before-each restart hook — restarting would foreground the host +# app and hide the native UI under test. +# 4. Biometric tests: pre-enroll fingerprint ID 1 in the emulator's +# Settings once (`enroll biometric` is a no-op on Android); `biometric +# match` / `biometric no match` drive `adb emu finger touch`. +# +# Element queries use the same names as the Flutter app's ValueKeys — the +# Compose app surfaces every Modifier.testTag as a uiautomator resource-id +# (testTagsAsResourceId), so `#key` selectors in Flutter tests map 1:1 to +# `native` queries here. Matching is case-insensitive substring over text +# and resource-id, and uiautomator only sees on-screen elements, so tests +# stick to elements visible without scrolling (there is no `scroll native`). + +test "home shell: tabs and nav list" + see native "FlutterProbe Test App" + see native "welcome_text" + see native "Version 1.0" + see native "nav_login" + see native "nav_dynamic" + tap native "tab_tests" + wait 1 seconds + see native "Test Suites" + tap native "tab_about" + wait 1 seconds + see native "About FlutterProbe Test App" + tap native "tab_home" + wait 1 seconds + see native "Welcome to the FlutterProbe Test App" + +test "login succeeds with valid credentials" + tap native "nav_login" + wait 2 seconds + see native "email_field" + type native "test@test.com" into "email_field" + type native "password" into "password_field" + wait 1 seconds + tap native "sign_in_button" + wait 3 seconds + see native "Dashboard" + see native "Welcome, Test User" + see native "stat_card_1" + tap native "logout_button" + wait 2 seconds + see native "welcome_text" + +test "login rejects invalid credentials" + tap native "nav_login" + wait 2 seconds + type native "wrong@test.com" into "email_field" + type native "nope" into "password_field" + wait 1 seconds + tap native "sign_in_button" + wait 3 seconds + see native "Invalid email or password" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "item list: read, add feedback, and search filter" + tap native "nav_items" + wait 2 seconds + see native "scrollable_list" + see native "list_item_0" + see native "Item 0" + tap native "fab_add" + wait 1 seconds + see native "Add item tapped" + type native "Item 49" into "search_field" + wait 2 seconds + see native "Item 49" + don't see native "Item 12" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "gesture recognition via native tap" + tap native "nav_gestures" + wait 2 seconds + see native "Gesture Count: 0" + see native "Taps: 0" + tap native "tap_capture_area" + wait 1 seconds + see native "Taps: 1" + see native "Gesture Count: 1" + tap native "tap_capture_area" + wait 1 seconds + see native "Taps: 2" + see native "Gesture Count: 2" + see native "drag_source" + see native "drag_target" + see native "double_tap_area" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "device capabilities: camera and GPS grants" + tap native "nav_device" + wait 2 seconds + see native "Camera: Not requested" + tap native "request_camera" + wait 1 seconds + see native "Camera: Granted" + tap native "request_location" + wait 1 seconds + see native "Location: Granted" + see native "Location: 37.7749, -122.4194" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "visual counter increments" + tap native "nav_visual" + wait 2 seconds + see native "Visual Test" + see native "Count: 0" + tap native "increment_button" + wait 1 seconds + see native "Count: 1" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "dynamic screen: error dialog opens and closes" + tap native "nav_dynamic" + wait 2 seconds + see native "Countdown" + see native "fade_widget" + tap native "trigger_error" + wait 1 seconds + see native "Something went wrong!" + tap native "OK" + wait 1 seconds + don't see native "Something went wrong!" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "signal demo: payment resolves" + tap native "nav_signal" + wait 2 seconds + see native "start_payment" + tap native "start_payment" + wait 3 seconds + see native "Payment confirmed" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "api demo: fetch users and create post" + tap native "nav_api" + wait 2 seconds + see native "API Demo" + tap native "fetch_users_button" + wait 3 seconds + see native "Leanne Graham" + see native "Clementine Bauch" + tap native "create_post_button" + wait 2 seconds + see native "Post created with id: 101" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "settings: language selection and save" + tap native "nav_settings" + wait 2 seconds + see native "dark_mode_toggle" + see native "notifications_toggle" + see native "terms_checkbox" + see native "Build: 1.0.42+dev" + tap native "language_dropdown" + wait 1 seconds + see native "Spanish" + tap native "Spanish" + wait 1 seconds + see native "Spanish" + tap native "save_button" + wait 1 seconds + see native "Settings saved" + tap native "back_button" + wait 2 seconds + see native "welcome_text" + +test "biometric: no match shows error, match reaches dashboard" + enroll biometric + tap native "nav_biometric" + wait 2 seconds + see native "biometric_prompt_intro" + tap native "sign_in_with_face_id" + wait 2 seconds + biometric no match + wait 2 seconds + see native "Authentication failed" + tap native "sign_in_with_face_id" + wait 2 seconds + biometric match + wait 3 seconds + see native "Welcome, Biometric User" + tap native "logout_button" + wait 2 seconds + see native "welcome_text" diff --git a/native/android-kotlin/settings.gradle.kts b/native/android-kotlin/settings.gradle.kts new file mode 100644 index 0000000..20964ef --- /dev/null +++ b/native/android-kotlin/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ProbeTestAppNative" +include(":app")