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")