diff --git a/README.md b/README.md index 3051ac0..9c0ddc1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 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, 82 E2E tests ``` ## Mobile App @@ -13,7 +13,7 @@ mobile/ Flutter test app (iOS + Android) — 11 screens, 78 E2E tests Purpose-built Flutter app for exercising every FlutterProbe framework feature: - **11 screens**: Home, Login, **Biometric Login (new in v0.9.7)**, Dashboard, Settings, Items, Gestures, API, Device, Visual, Dynamic -- **78 test files** covering: navigation, forms, gestures, HTTP mocking, visual regression, hooks, data-driven tests, and **Face ID / Touch ID / fingerprint flows (new)** +- **82 tests** covering: navigation, forms, gestures, HTTP mocking, visual regression, hooks, data-driven tests, **Face ID / Touch ID / fingerprint flows**, and **native UI-automation verbs (`tap native` / `see native` / `type native`)** - **Clean Architecture**: domain/data/presentation layers with Provider + get_it ### Biometric auth tests (v0.9.7) @@ -28,6 +28,20 @@ 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 UI-automation tests + +Every keyed widget carries both `ValueKey('name')` (Flutter-side `#name` selector) and +`Semantics(identifier: 'name')`, which Flutter maps to Android's `resource-id` / +iOS `accessibilityIdentifier` — so the native verb family resolves the same widgets +as the Dart-agent selectors. The `mobile/tests/native/` suite (Android-only, uses +uiautomator) runs against `mobile/tests/probe.android.yaml`, which carries the +Android package id (`com.alphawavesystems.probe_test_app`): + +```bash +cd mobile +probe test tests/native/ --config tests/probe.android.yaml --device emulator-5554 --reconnect-delay 5s -v -y +``` + ## Running Tests ```bash diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 846d731..3dbe6fe 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -102,44 +102,62 @@ class _ApiPageState extends State<_ApiPage> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ElevatedButton( - key: const ValueKey('fetch_users_button'), - onPressed: _loading ? null : _fetchUsers, - child: const Text('Fetch Users'), + Semantics( + identifier: 'fetch_users_button', + child: ElevatedButton( + key: const ValueKey('fetch_users_button'), + onPressed: _loading ? null : _fetchUsers, + child: const Text('Fetch Users'), + ), ), const SizedBox(height: 8), - ElevatedButton( - key: const ValueKey('create_post_button'), - onPressed: _loading ? null : _createPost, - child: const Text('Create Post'), + Semantics( + identifier: 'create_post_button', + child: ElevatedButton( + key: const ValueKey('create_post_button'), + onPressed: _loading ? null : _createPost, + child: const Text('Create Post'), + ), ), const SizedBox(height: 16), if (_loading) - const Center( - child: CircularProgressIndicator( - key: ValueKey('api_loading'), + Center( + child: Semantics( + identifier: 'api_loading', + child: const CircularProgressIndicator( + key: ValueKey('api_loading'), + ), ), ), if (_error != null) - Text( - _error!, - key: const ValueKey('api_error'), - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 16), + Semantics( + identifier: 'api_error', + child: Text( + _error!, + key: const ValueKey('api_error'), + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 16), + ), ), if (_users.isNotEmpty) ...[ const Text('Users:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), ...List.generate(_users.length, (i) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text(_users[i], key: ValueKey('user_$i')), + child: Semantics( + identifier: 'user_$i', + child: Text(_users[i], key: ValueKey('user_$i')), + ), )), ], if (_postResult != null) Padding( padding: const EdgeInsets.only(top: 16), - child: Text( - _postResult!, - key: const ValueKey('post_result'), - style: const TextStyle(fontSize: 16, color: Colors.green), + child: Semantics( + identifier: 'post_result', + child: Text( + _postResult!, + key: const ValueKey('post_result'), + style: const TextStyle(fontSize: 16, color: Colors.green), + ), ), ), ], @@ -160,10 +178,13 @@ class _PlaceholderPage extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(title)), body: Center( - child: Text( - '$title — Coming Soon', - key: ValueKey('placeholder_$title'), - style: Theme.of(context).textTheme.headlineMedium, + child: Semantics( + identifier: 'placeholder_$title', + child: Text( + '$title — Coming Soon', + key: ValueKey('placeholder_$title'), + style: Theme.of(context).textTheme.headlineMedium, + ), ), ), ); diff --git a/mobile/lib/features/auth/presentation/pages/biometric_login_page.dart b/mobile/lib/features/auth/presentation/pages/biometric_login_page.dart index 768dfd1..c8d119a 100644 --- a/mobile/lib/features/auth/presentation/pages/biometric_login_page.dart +++ b/mobile/lib/features/auth/presentation/pages/biometric_login_page.dart @@ -84,42 +84,54 @@ class _BiometricLoginPageState extends State { const SizedBox(height: 48), const Icon(Icons.face_retouching_natural, size: 96), const SizedBox(height: 16), - const Text( - 'Use your face or fingerprint to sign in.', - key: ValueKey('biometric_prompt_intro'), - textAlign: TextAlign.center, + Semantics( + identifier: 'biometric_prompt_intro', + child: const Text( + 'Use your face or fingerprint to sign in.', + key: ValueKey('biometric_prompt_intro'), + textAlign: TextAlign.center, + ), ), const SizedBox(height: 32), - ElevatedButton.icon( - key: const ValueKey('sign_in_with_face_id'), - onPressed: _busy ? null : _signInWithBiometrics, - icon: const Icon(Icons.face), - label: const Text('Sign in with Face ID'), + Semantics( + identifier: 'sign_in_with_face_id', + child: ElevatedButton.icon( + key: const ValueKey('sign_in_with_face_id'), + onPressed: _busy ? null : _signInWithBiometrics, + icon: const Icon(Icons.face), + label: const Text('Sign in with Face ID'), + ), ), if (_busy) ...[ const SizedBox(height: 24), - const Center( + Center( child: Padding( - padding: EdgeInsets.all(8), - child: CircularProgressIndicator( - key: ValueKey('biometric_progress'), + padding: const EdgeInsets.all(8), + child: Semantics( + identifier: 'biometric_progress', + child: const CircularProgressIndicator( + key: ValueKey('biometric_progress'), + ), ), ), ), ], if (_error != null) ...[ const SizedBox(height: 24), - Container( - key: const ValueKey('biometric_error_banner'), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.shade50, - border: Border.all(color: Colors.red.shade200), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _error!, - style: TextStyle(color: Colors.red.shade800), + Semantics( + identifier: 'biometric_error_banner', + child: Container( + key: const ValueKey('biometric_error_banner'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + border: Border.all(color: Colors.red.shade200), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _error!, + style: TextStyle(color: Colors.red.shade800), + ), ), ), ], diff --git a/mobile/lib/features/auth/presentation/pages/dashboard_page.dart b/mobile/lib/features/auth/presentation/pages/dashboard_page.dart index 3a0197f..52b37b5 100644 --- a/mobile/lib/features/auth/presentation/pages/dashboard_page.dart +++ b/mobile/lib/features/auth/presentation/pages/dashboard_page.dart @@ -17,22 +17,28 @@ class DashboardPage extends StatelessWidget { appBar: AppBar( title: const Text('Dashboard'), actions: [ - IconButton( - key: const ValueKey('refresh_button'), - icon: const Icon(Icons.refresh), - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Refreshed')), - ); - }, + Semantics( + identifier: 'refresh_button', + child: IconButton( + key: const ValueKey('refresh_button'), + icon: const Icon(Icons.refresh), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Refreshed')), + ); + }, + ), ), - IconButton( - key: const ValueKey('logout_button'), - icon: const Icon(Icons.logout), - onPressed: () { - auth.logout(); - Navigator.pushNamedAndRemoveUntil(context, Routes.home, (route) => false); - }, + Semantics( + identifier: 'logout_button', + child: IconButton( + key: const ValueKey('logout_button'), + icon: const Icon(Icons.logout), + onPressed: () { + auth.logout(); + Navigator.pushNamedAndRemoveUntil(context, Routes.home, (route) => false); + }, + ), ), ], ), @@ -45,28 +51,31 @@ class DashboardPage extends StatelessWidget { padding: const EdgeInsets.all(16.0), children: [ // Welcome banner - Card( - key: const ValueKey('welcome_banner'), - color: Theme.of(context).colorScheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Welcome, $userName', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(height: 4), - Text( - 'Here is your dashboard overview', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7), + Semantics( + identifier: 'welcome_banner', + child: Card( + key: const ValueKey('welcome_banner'), + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Welcome, $userName', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), ), - ), - ], + const SizedBox(height: 4), + Text( + 'Here is your dashboard overview', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7), + ), + ), + ], + ), ), ), ), @@ -76,29 +85,38 @@ class DashboardPage extends StatelessWidget { Row( children: [ Expanded( - child: _StatCard( - key: const ValueKey('stat_card_1'), - title: 'Tests Run', - value: '42', - icon: Icons.check_circle, + child: Semantics( + identifier: 'stat_card_1', + child: _StatCard( + key: const ValueKey('stat_card_1'), + title: 'Tests Run', + value: '42', + icon: Icons.check_circle, + ), ), ), const SizedBox(width: 8), Expanded( - child: _StatCard( - key: const ValueKey('stat_card_2'), - title: 'Passed', - value: '38', - icon: Icons.thumb_up, + child: Semantics( + identifier: 'stat_card_2', + child: _StatCard( + key: const ValueKey('stat_card_2'), + title: 'Passed', + value: '38', + icon: Icons.thumb_up, + ), ), ), const SizedBox(width: 8), Expanded( - child: _StatCard( - key: const ValueKey('stat_card_3'), - title: 'Failed', - value: '4', - icon: Icons.error_outline, + child: Semantics( + identifier: 'stat_card_3', + child: _StatCard( + key: const ValueKey('stat_card_3'), + title: 'Failed', + value: '4', + icon: Icons.error_outline, + ), ), ), ], @@ -114,19 +132,22 @@ class DashboardPage extends StatelessWidget { // Item list ...List.generate(10, (index) { - return ListTile( - key: ValueKey('item_$index'), - leading: CircleAvatar( - child: Text('${index + 1}'), + return Semantics( + identifier: 'item_$index', + child: ListTile( + key: ValueKey('item_$index'), + leading: CircleAvatar( + child: Text('${index + 1}'), + ), + title: Text('Item ${index + 1}'), + subtitle: Text('Description for item ${index + 1}'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Tapped item ${index + 1}')), + ); + }, ), - title: Text('Item ${index + 1}'), - subtitle: Text('Description for item ${index + 1}'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Tapped item ${index + 1}')), - ); - }, ); }), ], diff --git a/mobile/lib/features/auth/presentation/pages/login_page.dart b/mobile/lib/features/auth/presentation/pages/login_page.dart index 419487a..4909fcd 100644 --- a/mobile/lib/features/auth/presentation/pages/login_page.dart +++ b/mobile/lib/features/auth/presentation/pages/login_page.dart @@ -54,73 +54,91 @@ class _LoginPageState extends State { color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: 32), - TextField( - key: const ValueKey('email_field'), - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - hintText: 'Enter your email', - prefixIcon: Icon(Icons.email_outlined), - border: OutlineInputBorder(), + Semantics( + identifier: 'email_field', + child: TextField( + key: const ValueKey('email_field'), + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + hintText: 'Enter your email', + prefixIcon: Icon(Icons.email_outlined), + border: OutlineInputBorder(), + ), ), ), const SizedBox(height: 16), - TextField( - key: const ValueKey('password_field'), - controller: _passwordController, - obscureText: true, - decoration: const InputDecoration( - labelText: 'Password', - hintText: 'Enter your password', - prefixIcon: Icon(Icons.lock_outlined), - border: OutlineInputBorder(), + Semantics( + identifier: 'password_field', + child: TextField( + key: const ValueKey('password_field'), + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + hintText: 'Enter your password', + prefixIcon: Icon(Icons.lock_outlined), + border: OutlineInputBorder(), + ), ), ), const SizedBox(height: 8), if (auth.error != null) Padding( padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - auth.error!, - key: const ValueKey('error_message'), - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 14, + child: Semantics( + identifier: 'error_message', + child: Text( + auth.error!, + key: const ValueKey('error_message'), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 14, + ), ), ), ), const SizedBox(height: 16), if (auth.isLoading) - const Center( - child: CircularProgressIndicator( - key: ValueKey('loading_indicator'), + Center( + child: Semantics( + identifier: 'loading_indicator', + child: const CircularProgressIndicator( + key: ValueKey('loading_indicator'), + ), ), ) else - ElevatedButton( - key: const ValueKey('sign_in_button'), - onPressed: _handleSignIn, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - 'Sign In', - style: TextStyle(fontSize: 16), + Semantics( + identifier: 'sign_in_button', + child: ElevatedButton( + key: const ValueKey('sign_in_button'), + onPressed: _handleSignIn, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text( + 'Sign In', + style: TextStyle(fontSize: 16), + ), ), ), const SizedBox(height: 12), - TextButton( - key: const ValueKey('forgot_password'), - onPressed: () { - // Placeholder for forgot password flow - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Password reset not implemented'), - ), - ); - }, - child: const Text('Forgot Password?'), + Semantics( + identifier: 'forgot_password', + child: TextButton( + key: const ValueKey('forgot_password'), + onPressed: () { + // Placeholder for forgot password flow + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password reset not implemented'), + ), + ); + }, + child: const Text('Forgot Password?'), + ), ), ], ), diff --git a/mobile/lib/features/device/presentation/pages/device_page.dart b/mobile/lib/features/device/presentation/pages/device_page.dart index 683b395..9db40a2 100644 --- a/mobile/lib/features/device/presentation/pages/device_page.dart +++ b/mobile/lib/features/device/presentation/pages/device_page.dart @@ -39,49 +39,64 @@ class _DevicePageState extends State { const Text('Permissions', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 12), - ElevatedButton.icon( - key: const ValueKey('request_camera'), - onPressed: () { - setState(() { - _cameraStatus = 'Granted'; - }); - }, - icon: const Icon(Icons.camera_alt), - label: const Text('Request Camera'), + Semantics( + identifier: 'request_camera', + child: ElevatedButton.icon( + key: const ValueKey('request_camera'), + onPressed: () { + setState(() { + _cameraStatus = 'Granted'; + }); + }, + icon: const Icon(Icons.camera_alt), + label: const Text('Request Camera'), + ), ), const SizedBox(height: 4), - Text( - 'Camera: $_cameraStatus', - key: const ValueKey('camera_status'), + Semantics( + identifier: 'camera_status', + child: Text( + 'Camera: $_cameraStatus', + key: const ValueKey('camera_status'), + ), ), const SizedBox(height: 12), - ElevatedButton.icon( - key: const ValueKey('request_location'), - onPressed: () { - setState(() { - _locationStatus = 'Granted'; - _gpsDisplay = 'Location: 37.7749, -122.4194'; - }); - }, - icon: const Icon(Icons.location_on), - label: const Text('Request Location'), + Semantics( + identifier: 'request_location', + child: ElevatedButton.icon( + key: const ValueKey('request_location'), + onPressed: () { + setState(() { + _locationStatus = 'Granted'; + _gpsDisplay = 'Location: 37.7749, -122.4194'; + }); + }, + icon: const Icon(Icons.location_on), + label: const Text('Request Location'), + ), ), const SizedBox(height: 4), - Text( - 'Location: $_locationStatus', - key: const ValueKey('location_status'), + Semantics( + identifier: 'location_status', + child: Text( + 'Location: $_locationStatus', + key: const ValueKey('location_status'), + ), ), const SizedBox(height: 12), - ElevatedButton.icon( - key: const ValueKey('request_notifications'), - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Notification permission requested')), - ); - }, - icon: const Icon(Icons.notifications), - label: const Text('Request Notifications'), + Semantics( + identifier: 'request_notifications', + child: ElevatedButton.icon( + key: const ValueKey('request_notifications'), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Notification permission requested')), + ); + }, + icon: const Icon(Icons.notifications), + label: const Text('Request Notifications'), + ), ), const SizedBox(height: 24), @@ -89,10 +104,13 @@ class _DevicePageState extends State { const Text('GPS', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 8), - Text( - _gpsDisplay, - key: const ValueKey('gps_display'), - style: const TextStyle(fontSize: 16), + Semantics( + identifier: 'gps_display', + child: Text( + _gpsDisplay, + key: const ValueKey('gps_display'), + style: const TextStyle(fontSize: 16), + ), ), const SizedBox(height: 24), @@ -100,16 +118,19 @@ class _DevicePageState extends State { const Text('Browser', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 8), - ElevatedButton.icon( - key: const ValueKey('open_browser'), - onPressed: () async { - final url = Uri.parse('https://www.google.com'); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } - }, - icon: const Icon(Icons.open_in_browser), - label: const Text('Open Website'), + Semantics( + identifier: 'open_browser', + child: ElevatedButton.icon( + key: const ValueKey('open_browser'), + onPressed: () async { + final url = Uri.parse('https://www.google.com'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + icon: const Icon(Icons.open_in_browser), + label: const Text('Open Website'), + ), ), const SizedBox(height: 24), @@ -117,31 +138,40 @@ class _DevicePageState extends State { const Text('Clipboard', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 8), - TextField( - key: const ValueKey('copy_text_field'), - controller: _copyController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Text to copy', + Semantics( + identifier: 'copy_text_field', + child: TextField( + key: const ValueKey('copy_text_field'), + controller: _copyController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Text to copy', + ), ), ), const SizedBox(height: 8), - ElevatedButton.icon( - key: const ValueKey('paste_button'), - onPressed: () async { - final data = await Clipboard.getData(Clipboard.kTextPlain); - setState(() { - _pastedText = data?.text ?? 'Nothing in clipboard'; - }); - }, - icon: const Icon(Icons.paste), - label: const Text('Paste'), + Semantics( + identifier: 'paste_button', + child: ElevatedButton.icon( + key: const ValueKey('paste_button'), + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + setState(() { + _pastedText = data?.text ?? 'Nothing in clipboard'; + }); + }, + icon: const Icon(Icons.paste), + label: const Text('Paste'), + ), ), const SizedBox(height: 8), - Text( - _pastedText, - key: const ValueKey('pasted_text'), - style: const TextStyle(fontSize: 14), + Semantics( + identifier: 'pasted_text', + child: Text( + _pastedText, + key: const ValueKey('pasted_text'), + style: const TextStyle(fontSize: 14), + ), ), ], ), diff --git a/mobile/lib/features/dynamic/presentation/pages/dynamic_page.dart b/mobile/lib/features/dynamic/presentation/pages/dynamic_page.dart index 8e5d291..1951641 100644 --- a/mobile/lib/features/dynamic/presentation/pages/dynamic_page.dart +++ b/mobile/lib/features/dynamic/presentation/pages/dynamic_page.dart @@ -58,16 +58,19 @@ class _DynamicPageState extends State void _showErrorDialog() { showDialog( context: context, - builder: (context) => AlertDialog( - key: const ValueKey('error_dialog'), - title: const Text('Error'), - content: const Text('Something went wrong!'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], + builder: (context) => Semantics( + identifier: 'error_dialog', + child: AlertDialog( + key: const ValueKey('error_dialog'), + title: const Text('Error'), + content: const Text('Something went wrong!'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), ), ); } @@ -85,22 +88,25 @@ class _DynamicPageState extends State children: [ // A/B Banner if (_showAbBanner) - Container( - key: const ValueKey('ab_banner'), - width: double.infinity, - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.amber, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'Special Offer!', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + Semantics( + identifier: 'ab_banner', + child: Container( + key: const ValueKey('ab_banner'), + width: double.infinity, + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.amber, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Special Offer!', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ), @@ -109,13 +115,16 @@ class _DynamicPageState extends State style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 8), Center( - child: Text( - '$_countdown', - key: const ValueKey('countdown'), - style: TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - color: _countdown <= 3 ? Colors.red : Colors.black, + child: Semantics( + identifier: 'countdown', + child: Text( + '$_countdown', + key: const ValueKey('countdown'), + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: _countdown <= 3 ? Colors.red : Colors.black, + ), ), ), ), @@ -127,18 +136,21 @@ class _DynamicPageState extends State const SizedBox(height: 8), FadeTransition( opacity: _fadeAnimation, - child: Container( - key: const ValueKey('fade_widget'), - width: double.infinity, - height: 80, - decoration: BoxDecoration( - color: Colors.teal, - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: const Text( - 'I faded in!', - style: TextStyle(color: Colors.white, fontSize: 18), + child: Semantics( + identifier: 'fade_widget', + child: Container( + key: const ValueKey('fade_widget'), + width: double.infinity, + height: 80, + decoration: BoxDecoration( + color: Colors.teal, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text( + 'I faded in!', + style: TextStyle(color: Colors.white, fontSize: 18), + ), ), ), ), @@ -148,14 +160,17 @@ class _DynamicPageState extends State const Text('Error Handling', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 8), - ElevatedButton( - key: const ValueKey('trigger_error'), - onPressed: _showErrorDialog, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, + Semantics( + identifier: 'trigger_error', + child: ElevatedButton( + key: const ValueKey('trigger_error'), + onPressed: _showErrorDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Trigger Error'), ), - child: const Text('Trigger Error'), ), const SizedBox(height: 24), @@ -163,20 +178,26 @@ class _DynamicPageState extends State const Text('Repeatable Action', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 8), - ElevatedButton( - key: const ValueKey('repeat_action'), - onPressed: () { - setState(() { - _actionCount++; - }); - }, - child: const Text('Tap Me'), + Semantics( + identifier: 'repeat_action', + child: ElevatedButton( + key: const ValueKey('repeat_action'), + onPressed: () { + setState(() { + _actionCount++; + }); + }, + child: const Text('Tap Me'), + ), ), const SizedBox(height: 8), - Text( - 'Tapped: $_actionCount', - key: const ValueKey('action_count'), - style: const TextStyle(fontSize: 16), + Semantics( + identifier: 'action_count', + child: Text( + 'Tapped: $_actionCount', + key: const ValueKey('action_count'), + style: const TextStyle(fontSize: 16), + ), ), ], ), diff --git a/mobile/lib/features/gestures/presentation/pages/gestures_page.dart b/mobile/lib/features/gestures/presentation/pages/gestures_page.dart index 86c5af3..f93e1b7 100644 --- a/mobile/lib/features/gestures/presentation/pages/gestures_page.dart +++ b/mobile/lib/features/gestures/presentation/pages/gestures_page.dart @@ -30,10 +30,13 @@ class _GesturesPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Gesture Count: $_gestureCount', - key: const ValueKey('gesture_count'), - style: Theme.of(context).textTheme.titleMedium, + Semantics( + identifier: 'gesture_count', + child: Text( + 'Gesture Count: $_gestureCount', + key: const ValueKey('gesture_count'), + style: Theme.of(context).textTheme.titleMedium, + ), ), const SizedBox(height: 24), @@ -57,15 +60,18 @@ class _GesturesPageState extends State { color: Colors.blue.withOpacity(0.3), ), onDragCompleted: _incrementGestureCount, - child: Container( - key: const ValueKey('drag_source'), - width: 80, - height: 80, - color: Colors.blue, - alignment: Alignment.center, - child: const Text( - 'Drag', - style: TextStyle(color: Colors.white), + child: Semantics( + identifier: 'drag_source', + child: Container( + key: const ValueKey('drag_source'), + width: 80, + height: 80, + color: Colors.blue, + alignment: Alignment.center, + child: const Text( + 'Drag', + style: TextStyle(color: Colors.white), + ), ), ), ), @@ -76,15 +82,18 @@ class _GesturesPageState extends State { }); }, builder: (context, candidateData, rejectedData) { - return Container( - key: const ValueKey('drag_target'), - width: 80, - height: 80, - color: _dragAccepted ? Colors.greenAccent : Colors.green, - alignment: Alignment.center, - child: Text( - _dragAccepted ? 'Done!' : 'Drop', - style: const TextStyle(color: Colors.white), + return Semantics( + identifier: 'drag_target', + child: Container( + key: const ValueKey('drag_target'), + width: 80, + height: 80, + color: _dragAccepted ? Colors.greenAccent : Colors.green, + alignment: Alignment.center, + child: Text( + _dragAccepted ? 'Done!' : 'Drop', + style: const TextStyle(color: Colors.white), + ), ), ); }, @@ -97,22 +106,25 @@ class _GesturesPageState extends State { const Text('Double Tap', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), - GestureDetector( - key: const ValueKey('double_tap_area'), - onDoubleTap: () { - setState(() { - _doubleTapCount++; - }); - _incrementGestureCount(); - }, - child: Container( - width: double.infinity, - height: 80, - color: Colors.orange.shade100, - alignment: Alignment.center, - child: Text( - 'Double taps: $_doubleTapCount', - style: const TextStyle(fontSize: 16), + Semantics( + identifier: 'double_tap_area', + child: GestureDetector( + key: const ValueKey('double_tap_area'), + onDoubleTap: () { + setState(() { + _doubleTapCount++; + }); + _incrementGestureCount(); + }, + child: Container( + width: double.infinity, + height: 80, + color: Colors.orange.shade100, + alignment: Alignment.center, + child: Text( + 'Double taps: $_doubleTapCount', + style: const TextStyle(fontSize: 16), + ), ), ), ), @@ -122,38 +134,41 @@ class _GesturesPageState extends State { const Text('Long Press', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), - GestureDetector( - key: const ValueKey('long_press_area'), - onLongPressStart: (details) { - _incrementGestureCount(); - final overlay = Overlay.of(context).context.findRenderObject() - as RenderBox; - showMenu( - context: context, - position: RelativeRect.fromRect( - details.globalPosition & const Size(1, 1), - Offset.zero & overlay.size, - ), - items: [ - const PopupMenuItem( - value: 'copy', - child: Text('Copy'), - ), - const PopupMenuItem( - value: 'delete', - child: Text('Delete'), + Semantics( + identifier: 'long_press_area', + child: GestureDetector( + key: const ValueKey('long_press_area'), + onLongPressStart: (details) { + _incrementGestureCount(); + final overlay = Overlay.of(context).context.findRenderObject() + as RenderBox; + showMenu( + context: context, + position: RelativeRect.fromRect( + details.globalPosition & const Size(1, 1), + Offset.zero & overlay.size, ), - ], - ); - }, - child: Container( - width: double.infinity, - height: 80, - color: Colors.purple.shade100, - alignment: Alignment.center, - child: const Text( - 'Long press me', - style: TextStyle(fontSize: 16), + items: [ + const PopupMenuItem( + value: 'copy', + child: Text('Copy'), + ), + const PopupMenuItem( + value: 'delete', + child: Text('Delete'), + ), + ], + ); + }, + child: Container( + width: double.infinity, + height: 80, + color: Colors.purple.shade100, + alignment: Alignment.center, + child: const Text( + 'Long press me', + style: TextStyle(fontSize: 16), + ), ), ), ), @@ -164,25 +179,28 @@ class _GesturesPageState extends State { style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), if (_swipeCardVisible) - Dismissible( - key: const ValueKey('swipe_card'), - onDismissed: (_) { - setState(() { - _swipeCardVisible = false; - }); - _incrementGestureCount(); - }, - background: Container( - color: Colors.red, - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - child: - const Icon(Icons.delete, color: Colors.white), - ), - child: Card( - child: ListTile( - title: const Text('Swipe me to dismiss'), - leading: const Icon(Icons.swipe), + Semantics( + identifier: 'swipe_card', + child: Dismissible( + key: const ValueKey('swipe_card'), + onDismissed: (_) { + setState(() { + _swipeCardVisible = false; + }); + _incrementGestureCount(); + }, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: + const Icon(Icons.delete, color: Colors.white), + ), + child: Card( + child: ListTile( + title: const Text('Swipe me to dismiss'), + leading: const Icon(Icons.swipe), + ), ), ), ) diff --git a/mobile/lib/features/items/presentation/pages/item_list_page.dart b/mobile/lib/features/items/presentation/pages/item_list_page.dart index 65afef1..bc55b75 100644 --- a/mobile/lib/features/items/presentation/pages/item_list_page.dart +++ b/mobile/lib/features/items/presentation/pages/item_list_page.dart @@ -18,14 +18,17 @@ class ItemListPage extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.all(16), - child: TextField( - key: const ValueKey('search_field'), - decoration: const InputDecoration( - hintText: 'Search items...', - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), + child: Semantics( + identifier: 'search_field', + child: TextField( + key: const ValueKey('search_field'), + decoration: const InputDecoration( + hintText: 'Search items...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onChanged: provider.search, ), - onChanged: provider.search, ), ), Expanded( @@ -33,56 +36,71 @@ class ItemListPage extends StatelessWidget { ? const Center(child: CircularProgressIndicator()) : provider.items.isEmpty ? Center( - child: Text( - 'No items found', - key: const ValueKey('empty_state'), - style: Theme.of(context).textTheme.titleMedium, + child: Semantics( + identifier: 'empty_state', + child: Text( + 'No items found', + key: const ValueKey('empty_state'), + style: Theme.of(context).textTheme.titleMedium, + ), ), ) - : ListView.builder( - key: const ValueKey('scrollable_list'), - itemCount: provider.items.length, - itemBuilder: (context, index) { - final item = provider.items[index]; - return Dismissible( - key: ValueKey('dismissible_${item.id}'), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red, - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - child: const Icon( - Icons.delete, - color: Colors.white, + : Semantics( + identifier: 'scrollable_list', + child: ListView.builder( + key: const ValueKey('scrollable_list'), + itemCount: provider.items.length, + itemBuilder: (context, index) { + final item = provider.items[index]; + return Semantics( + identifier: 'dismissible_${item.id}', + child: Dismissible( + key: ValueKey('dismissible_${item.id}'), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon( + Icons.delete, + color: Colors.white, + ), + ), + onDismissed: (_) { + provider.deleteItem(item.id); + }, + child: Semantics( + identifier: 'list_item_$index', + child: ListTile( + key: ValueKey('list_item_$index'), + title: Text(item.title), + subtitle: Text(item.description), + leading: CircleAvatar( + child: Text('${item.id}'), + ), + ), + ), ), - ), - onDismissed: (_) { - provider.deleteItem(item.id); - }, - child: ListTile( - key: ValueKey('list_item_$index'), - title: Text(item.title), - subtitle: Text(item.description), - leading: CircleAvatar( - child: Text('${item.id}'), - ), - ), - ); - }, + ); + }, + ), ), ), ], ); }, ), - floatingActionButton: FloatingActionButton( - key: const ValueKey('fab_add'), - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Add item tapped')), - ); - }, - child: const Icon(Icons.add), + floatingActionButton: Semantics( + identifier: 'fab_add', + child: FloatingActionButton( + key: const ValueKey('fab_add'), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Add item tapped')), + ); + }, + child: const Icon(Icons.add), + ), ), ); } diff --git a/mobile/lib/features/settings/presentation/pages/settings_page.dart b/mobile/lib/features/settings/presentation/pages/settings_page.dart index f7b715c..ff3bfd4 100644 --- a/mobile/lib/features/settings/presentation/pages/settings_page.dart +++ b/mobile/lib/features/settings/presentation/pages/settings_page.dart @@ -25,76 +25,97 @@ class SettingsPage extends StatelessWidget { return ListView( padding: const EdgeInsets.all(16), children: [ - SwitchListTile( - key: const ValueKey('dark_mode_toggle'), - title: const Text('Dark Mode'), - value: provider.darkMode, - onChanged: provider.toggleDarkMode, + Semantics( + identifier: 'dark_mode_toggle', + child: SwitchListTile( + key: const ValueKey('dark_mode_toggle'), + title: const Text('Dark Mode'), + value: provider.darkMode, + onChanged: provider.toggleDarkMode, + ), ), - SwitchListTile( - key: const ValueKey('notifications_toggle'), - title: const Text('Notifications'), - value: provider.notifications, - onChanged: provider.toggleNotifications, + Semantics( + identifier: 'notifications_toggle', + child: SwitchListTile( + key: const ValueKey('notifications_toggle'), + title: const Text('Notifications'), + value: provider.notifications, + onChanged: provider.toggleNotifications, + ), ), - CheckboxListTile( - key: const ValueKey('terms_checkbox'), - title: const Text('Agree to Terms'), - value: provider.termsAccepted, - onChanged: (value) { - if (value != null) { - provider.toggleTermsAccepted(value); - } - }, + Semantics( + identifier: 'terms_checkbox', + child: CheckboxListTile( + key: const ValueKey('terms_checkbox'), + title: const Text('Agree to Terms'), + value: provider.termsAccepted, + onChanged: (value) { + if (value != null) { + provider.toggleTermsAccepted(value); + } + }, + ), ), const SizedBox(height: 16), - DropdownButtonFormField( - key: const ValueKey('language_dropdown'), - decoration: const InputDecoration( - labelText: 'Language', - border: OutlineInputBorder(), + Semantics( + identifier: 'language_dropdown', + child: DropdownButtonFormField( + key: const ValueKey('language_dropdown'), + decoration: const InputDecoration( + labelText: 'Language', + border: OutlineInputBorder(), + ), + value: provider.language, + items: _languages + .map((lang) => DropdownMenuItem( + value: lang, + child: Text(lang), + )) + .toList(), + onChanged: (value) { + if (value != null) { + provider.setLanguage(value); + } + }, ), - value: provider.language, - items: _languages - .map((lang) => DropdownMenuItem( - value: lang, - child: Text(lang), - )) - .toList(), - onChanged: (value) { - if (value != null) { - provider.setLanguage(value); - } - }, ), const SizedBox(height: 24), - ElevatedButton( - key: const ValueKey('save_button'), - onPressed: () { - provider.savePreferences(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Settings saved')), - ); - }, - child: const Text('Save'), + Semantics( + identifier: 'save_button', + child: ElevatedButton( + key: const ValueKey('save_button'), + onPressed: () { + provider.savePreferences(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings saved')), + ); + }, + child: const Text('Save'), + ), ), const SizedBox(height: 12), - ElevatedButton( - key: const ValueKey('delete_button'), - onPressed: null, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey, + Semantics( + identifier: 'delete_button', + child: ElevatedButton( + key: const ValueKey('delete_button'), + onPressed: null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + ), + child: const Text('Delete Account'), ), - child: const Text('Delete Account'), ), const SizedBox(height: 24), - Text( - 'Build: 1.0.42+dev', - key: const ValueKey('build_info'), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), - textAlign: TextAlign.center, + Semantics( + identifier: 'build_info', + child: Text( + 'Build: 1.0.42+dev', + key: const ValueKey('build_info'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), ), ], ); diff --git a/mobile/lib/features/signal/presentation/pages/signal_demo_page.dart b/mobile/lib/features/signal/presentation/pages/signal_demo_page.dart index 8857176..735d56c 100644 --- a/mobile/lib/features/signal/presentation/pages/signal_demo_page.dart +++ b/mobile/lib/features/signal/presentation/pages/signal_demo_page.dart @@ -77,38 +77,53 @@ class _SignalDemoPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 24), - ElevatedButton( - key: const ValueKey('request_push_permission'), - onPressed: _busy ? null : _requestPushPermission, - child: const Text('Request Push Permission'), + Semantics( + identifier: 'request_push_permission', + child: ElevatedButton( + key: const ValueKey('request_push_permission'), + onPressed: _busy ? null : _requestPushPermission, + child: const Text('Request Push Permission'), + ), ), const SizedBox(height: 12), - ElevatedButton( - key: const ValueKey('start_payment'), - onPressed: _busy ? null : _startPayment, - child: const Text('Start Payment'), + Semantics( + identifier: 'start_payment', + child: ElevatedButton( + key: const ValueKey('start_payment'), + onPressed: _busy ? null : _startPayment, + child: const Text('Start Payment'), + ), ), const SizedBox(height: 12), - ElevatedButton( - key: const ValueKey('open_deep_link'), - onPressed: _busy ? null : _openDeepLink, - child: const Text('Open Deep Link'), + Semantics( + identifier: 'open_deep_link', + child: ElevatedButton( + key: const ValueKey('open_deep_link'), + onPressed: _busy ? null : _openDeepLink, + child: const Text('Open Deep Link'), + ), ), if (_busy) ...[ const SizedBox(height: 24), - const Center( - child: CircularProgressIndicator( - key: ValueKey('signal_progress'), + Center( + child: Semantics( + identifier: 'signal_progress', + child: const CircularProgressIndicator( + key: ValueKey('signal_progress'), + ), ), ), ], if (_status.isNotEmpty) ...[ const SizedBox(height: 24), - Text( - _status, - key: const ValueKey('signal_status'), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, + Semantics( + identifier: 'signal_status', + child: Text( + _status, + key: const ValueKey('signal_status'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), ), ], ], diff --git a/mobile/lib/features/visual/presentation/pages/visual_page.dart b/mobile/lib/features/visual/presentation/pages/visual_page.dart index 548eef7..8ea2a0b 100644 --- a/mobile/lib/features/visual/presentation/pages/visual_page.dart +++ b/mobile/lib/features/visual/presentation/pages/visual_page.dart @@ -22,146 +22,164 @@ class _VisualPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Static header - Container( - key: const ValueKey('visual_header'), - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.deepPurple, - borderRadius: BorderRadius.circular(12), - ), - child: const Text( - 'Visual Test', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, + Semantics( + identifier: 'visual_header', + child: Container( + key: const ValueKey('visual_header'), + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.deepPurple, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'Visual Test', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ), const SizedBox(height: 24), // Color grid - GridView.count( - key: const ValueKey('color_grid'), - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 8, - crossAxisSpacing: 8, - children: [ - Container( - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(8), + Semantics( + identifier: 'color_grid', + child: GridView.count( + key: const ValueKey('color_grid'), + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text('Red', + style: TextStyle(color: Colors.white, fontSize: 18)), ), - alignment: Alignment.center, - child: const Text('Red', - style: TextStyle(color: Colors.white, fontSize: 18)), - ), - Container( - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(8), + Container( + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text('Green', + style: TextStyle(color: Colors.white, fontSize: 18)), ), - alignment: Alignment.center, - child: const Text('Green', - style: TextStyle(color: Colors.white, fontSize: 18)), - ), - Container( - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(8), + Container( + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text('Blue', + style: TextStyle(color: Colors.white, fontSize: 18)), ), - alignment: Alignment.center, - child: const Text('Blue', - style: TextStyle(color: Colors.white, fontSize: 18)), - ), - Container( - decoration: BoxDecoration( - color: Colors.yellow, - borderRadius: BorderRadius.circular(8), + Container( + decoration: BoxDecoration( + color: Colors.yellow, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text('Yellow', + style: TextStyle(fontSize: 18)), ), - alignment: Alignment.center, - child: const Text('Yellow', - style: TextStyle(fontSize: 18)), - ), - ], + ], + ), ), const SizedBox(height: 24), // Counter Center( - child: Text( - 'Count: $_counter', - key: const ValueKey('visual_counter'), - style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + child: Semantics( + identifier: 'visual_counter', + child: Text( + 'Count: $_counter', + key: const ValueKey('visual_counter'), + style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + ), ), ), const SizedBox(height: 12), Center( - child: ElevatedButton( - key: const ValueKey('increment_button'), - onPressed: () { - setState(() { - _counter++; - }); - }, - child: const Text('Increment'), + child: Semantics( + identifier: 'increment_button', + child: ElevatedButton( + key: const ValueKey('increment_button'), + onPressed: () { + setState(() { + _counter++; + }); + }, + child: const Text('Increment'), + ), ), ), const SizedBox(height: 24), // Static image placeholder - Container( - key: const ValueKey('static_image'), - width: double.infinity, - height: 150, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey), - ), - alignment: Alignment.center, - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.image, size: 48, color: Colors.grey), - SizedBox(height: 8), - Text('Image Placeholder', - style: TextStyle(color: Colors.grey)), - ], + Semantics( + identifier: 'static_image', + child: Container( + key: const ValueKey('static_image'), + width: double.infinity, + height: 150, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey), + ), + alignment: Alignment.center, + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.image, size: 48, color: Colors.grey), + SizedBox(height: 8), + Text('Image Placeholder', + style: TextStyle(color: Colors.grey)), + ], + ), ), ), const SizedBox(height: 24), // Typography sample - Container( - key: const ValueKey('typography_sample'), - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Heading 1', - style: - TextStyle(fontSize: 32, fontWeight: FontWeight.bold)), - SizedBox(height: 8), - Text('Heading 2', - style: - TextStyle(fontSize: 24, fontWeight: FontWeight.w600)), - SizedBox(height: 8), - Text('Body text at 16px', style: TextStyle(fontSize: 16)), - SizedBox(height: 8), - Text('Caption text at 12px', style: TextStyle(fontSize: 12)), - SizedBox(height: 8), - Text('Small text at 10px', style: TextStyle(fontSize: 10)), - ], + Semantics( + identifier: 'typography_sample', + child: Container( + key: const ValueKey('typography_sample'), + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Heading 1', + style: + TextStyle(fontSize: 32, fontWeight: FontWeight.bold)), + SizedBox(height: 8), + Text('Heading 2', + style: + TextStyle(fontSize: 24, fontWeight: FontWeight.w600)), + SizedBox(height: 8), + Text('Body text at 16px', style: TextStyle(fontSize: 16)), + SizedBox(height: 8), + Text('Caption text at 12px', style: TextStyle(fontSize: 12)), + SizedBox(height: 8), + Text('Small text at 10px', style: TextStyle(fontSize: 10)), + ], + ), ), ), ], diff --git a/mobile/lib/navigation/home_page.dart b/mobile/lib/navigation/home_page.dart index f066e3a..ec9a7e4 100644 --- a/mobile/lib/navigation/home_page.dart +++ b/mobile/lib/navigation/home_page.dart @@ -22,21 +22,30 @@ class _HomePageState extends State { bottomNavigationBar: BottomNavigationBar( currentIndex: _currentTabIndex, onTap: (index) => setState(() => _currentTabIndex = index), - items: const [ + items: [ BottomNavigationBarItem( - icon: Icon(Icons.home, key: ValueKey('tab_home_icon')), + icon: Semantics( + identifier: 'tab_home_icon', + child: const Icon(Icons.home, key: ValueKey('tab_home_icon')), + ), label: 'Home', - key: ValueKey('tab_home'), + key: const ValueKey('tab_home'), ), BottomNavigationBarItem( - icon: Icon(Icons.science, key: ValueKey('tab_tests_icon')), + icon: Semantics( + identifier: 'tab_tests_icon', + child: const Icon(Icons.science, key: ValueKey('tab_tests_icon')), + ), label: 'Tests', - key: ValueKey('tab_tests'), + key: const ValueKey('tab_tests'), ), BottomNavigationBarItem( - icon: Icon(Icons.info, key: ValueKey('tab_about_icon')), + icon: Semantics( + identifier: 'tab_about_icon', + child: const Icon(Icons.info, key: ValueKey('tab_about_icon')), + ), label: 'About', - key: ValueKey('tab_about'), + key: const ValueKey('tab_about'), ), ], ), @@ -46,19 +55,25 @@ class _HomePageState extends State { Widget _buildBody() { switch (_currentTabIndex) { case 1: - return const Center( - child: Text( - 'Test Suites', - key: ValueKey('tests_tab_content'), - style: TextStyle(fontSize: 18), + return Center( + child: Semantics( + identifier: 'tests_tab_content', + child: const Text( + 'Test Suites', + key: ValueKey('tests_tab_content'), + style: TextStyle(fontSize: 18), + ), ), ); case 2: - return const Center( - child: Text( - 'About FlutterProbe Test App', - key: ValueKey('about_tab_content'), - style: TextStyle(fontSize: 18), + return Center( + child: Semantics( + identifier: 'about_tab_content', + child: const Text( + 'About FlutterProbe Test App', + key: ValueKey('about_tab_content'), + style: TextStyle(fontSize: 18), + ), ), ); default: @@ -69,13 +84,16 @@ class _HomePageState extends State { Widget _buildHomeContent() { return Column( children: [ - const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - 'Welcome to the FlutterProbe Test App', - key: ValueKey('welcome_text'), - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, + Padding( + padding: const EdgeInsets.all(16.0), + child: Semantics( + identifier: 'welcome_text', + child: const Text( + 'Welcome to the FlutterProbe Test App', + key: ValueKey('welcome_text'), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), ), ), Expanded( @@ -95,12 +113,15 @@ class _HomePageState extends State { ], ), ), - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - 'Version 1.0', - key: ValueKey('version_text'), - style: TextStyle(color: Colors.grey, fontSize: 12), + Padding( + padding: const EdgeInsets.all(8.0), + child: Semantics( + identifier: 'version_text', + child: const Text( + 'Version 1.0', + key: ValueKey('version_text'), + style: TextStyle(color: Colors.grey, fontSize: 12), + ), ), ), ], @@ -108,12 +129,15 @@ class _HomePageState extends State { } Widget _navTile(String title, IconData icon, String route, String keyName) { - return ListTile( - key: ValueKey(keyName), - leading: Icon(icon), - title: Text(title), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.pushNamed(context, route), + return Semantics( + identifier: keyName, + child: ListTile( + key: ValueKey(keyName), + leading: Icon(icon), + title: Text(title), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pushNamed(context, route), + ), ); } } diff --git a/mobile/tests/native/device.probe b/mobile/tests/native/device.probe new file mode 100644 index 0000000..37b1bff --- /dev/null +++ b/mobile/tests/native/device.probe @@ -0,0 +1,21 @@ +# Device-capability screen driven by native taps — Android only. +# A `tap native` on the resource-id must mutate the same state that the +# Flutter-side `see` then asserts, proving both selector styles hit one widget. + +test "native: device capability tap drives same widget as ValueKey" + @native + restart the app + wait 5 seconds + tap native "nav_device" + wait 2 seconds + see native "request_camera" + see "Camera: Not requested" + tap native "request_camera" + wait 1 seconds + see native "camera_status" + see "Camera: Granted" + tap native "request_location" + wait 1 seconds + see native "gps_display" + see "Location: Granted" + see "Location: 37.7749, -122.4194" diff --git a/mobile/tests/native/gestures.probe b/mobile/tests/native/gestures.probe new file mode 100644 index 0000000..e4c6dc8 --- /dev/null +++ b/mobile/tests/native/gestures.probe @@ -0,0 +1,20 @@ +# Gesture surfaces exposed by resource-id — Android only. +# Native verbs only cover tap/type/see, so the drag/double-tap/long-press/swipe +# interactions themselves stay in the Flutter-side suites; here we prove the +# gesture hit-areas are reachable from the OS accessibility tree. + +test "native: gesture areas exposed by resource-id" + @native + restart the app + wait 5 seconds + tap native "nav_gestures" + wait 2 seconds + see native "gesture_count" + see "Gesture Count: 0" + see native "drag_source" + see native "drag_target" + see native "double_tap_area" + scroll down + wait 1 seconds + see native "long_press_area" + see native "swipe_card" diff --git a/mobile/tests/native/home_tabs.probe b/mobile/tests/native/home_tabs.probe new file mode 100644 index 0000000..a3faa1d --- /dev/null +++ b/mobile/tests/native/home_tabs.probe @@ -0,0 +1,30 @@ +# Native UI-automation verb coverage — Android only (uiautomator resource-id). +# +# Every widget in this app carries both a ValueKey('name') (Flutter-side #name +# selector) and a Semantics(identifier: 'name') wrapper, which Flutter maps to +# Android's viewIdResourceName / iOS accessibilityIdentifier. These tests drive +# the app purely through the OS accessibility tree (`tap native` / `see native`) +# and cross-check with Flutter-side `see` to prove both selector styles resolve +# to the same widget. +# +# Run with the Android config: +# probe test tests/native/ --config tests/probe.android.yaml -v -y + +test "native: home tab navigation by resource-id" + @native + restart the app + wait 5 seconds + see native "welcome_text" + see native "version_text" + tap native "tab_tests_icon" + wait 1 seconds + see native "tests_tab_content" + see "Test Suites" + tap native "tab_about_icon" + wait 1 seconds + see native "about_tab_content" + see "About FlutterProbe Test App" + tap native "tab_home_icon" + wait 1 seconds + see native "welcome_text" + see "Welcome to the FlutterProbe Test App" diff --git a/mobile/tests/native/items.probe b/mobile/tests/native/items.probe new file mode 100644 index 0000000..a6ece20 --- /dev/null +++ b/mobile/tests/native/items.probe @@ -0,0 +1,17 @@ +# Items list driven through the native accessibility tree — Android only. +# See home_tabs.probe for background on the ValueKey + Semantics identifier pairing. + +test "native: items list search filters via native type" + @native + restart the app + wait 5 seconds + tap native "nav_items" + wait 2 seconds + see native "search_field" + see native "fab_add" + see native "list_item_0" + type native "zzz no such item" into "search_field" + wait 1 seconds + see native "empty_state" + don't see native "list_item_0" + see "No items found" diff --git a/mobile/tests/probe.android.yaml b/mobile/tests/probe.android.yaml new file mode 100644 index 0000000..db32a7f --- /dev/null +++ b/mobile/tests/probe.android.yaml @@ -0,0 +1,13 @@ +project: + name: "FlutterProbe E2E Test App (Android)" + app: com.alphawavesystems.probe_test_app +defaults: + platform: android + timeout: 30s + screenshots: on_failure + retry_failed_tests: 1 +agent: + port: 48686 + dial_timeout: 30s +recipes_folder: tests/recipes +reports_folder: reports