From 0c24f6ac991a6b1391df83ef2f1a1fbe43c81bc4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:23:44 -0400 Subject: [PATCH 1/3] Add stable cloud beta automation hooks --- lib/widgets/dialogs/auth/auth_dialog.dart | 118 +++---- lib/widgets/folder_navigator.dart | 295 ++++++++++-------- .../cloud_beta_automation_semantics_test.dart | 91 ++++++ 3 files changed, 324 insertions(+), 180 deletions(-) create mode 100644 test/widgets/cloud_beta_automation_semantics_test.dart diff --git a/lib/widgets/dialogs/auth/auth_dialog.dart b/lib/widgets/dialogs/auth/auth_dialog.dart index 5853be67..13d806d0 100644 --- a/lib/widgets/dialogs/auth/auth_dialog.dart +++ b/lib/widgets/dialogs/auth/auth_dialog.dart @@ -158,35 +158,50 @@ class _AuthDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - CustomTextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.email], - hintText: 'Email', - textInputAction: TextInputAction.next, - hasError: _errorField == _AuthField.email, + Semantics( + key: const ValueKey('auth-email-field'), + label: 'Email', + textField: true, + child: CustomTextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + hintText: 'Email', + textInputAction: TextInputAction.next, + hasError: _errorField == _AuthField.email, + ), ), const SizedBox(height: 10), - CustomTextField( - controller: _passwordController, - obscureText: true, - autofillHints: const [AutofillHints.password], - hintText: 'Password', - textInputAction: - _isSignUp ? TextInputAction.next : TextInputAction.done, - onSubmitted: _isSignUp ? null : (_) => _submit(), - hasError: _errorField == _AuthField.password, + Semantics( + key: const ValueKey('auth-password-field'), + label: 'Password', + textField: true, + child: CustomTextField( + controller: _passwordController, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Password', + textInputAction: + _isSignUp ? TextInputAction.next : TextInputAction.done, + onSubmitted: _isSignUp ? null : (_) => _submit(), + hasError: _errorField == _AuthField.password, + ), ), if (_isSignUp) ...[ const SizedBox(height: 10), - CustomTextField( - controller: _confirmController, - obscureText: true, - autofillHints: const [AutofillHints.password], - hintText: 'Confirm password', - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - hasError: _errorField == _AuthField.confirm, + Semantics( + key: const ValueKey('auth-confirm-password-field'), + label: 'Confirm password', + textField: true, + child: CustomTextField( + controller: _confirmController, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Confirm password', + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + hasError: _errorField == _AuthField.confirm, + ), ), ], const SizedBox(height: 12), @@ -223,6 +238,7 @@ class _AuthDialogState extends ConsumerState { children: [ Expanded( child: ShadButton.secondary( + key: const ValueKey('auth-mode-switch'), onPressed: busy ? null : () { @@ -234,43 +250,35 @@ class _AuthDialogState extends ConsumerState { }); }, child: Text( - _isSignUp - ? 'Already have an account? Sign in' - : 'Need an account? Sign up', + _isSignUp ? 'Sign in instead' : 'Create an account', ), ), ), ], ), const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: ShadButton.secondary( - onPressed: - busy || _waitingForDiscord ? null : _startDiscordSignIn, - child: const Text('Continue with Discord'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: ShadButton( - onPressed: busy ? null : _submit, - leading: busy - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, - child: Text( - busy - ? (_isSignUp ? 'Creating…' : 'Signing in…') - : (_isSignUp ? 'Create account' : 'Sign in'), - ), - ), - ), - ], + ShadButton.secondary( + key: const ValueKey('auth-discord-button'), + onPressed: + busy || _waitingForDiscord ? null : _startDiscordSignIn, + child: const Text('Continue with Discord'), + ), + const SizedBox(height: 10), + ShadButton( + key: const ValueKey('auth-submit-button'), + onPressed: busy ? null : _submit, + leading: busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + child: Text( + busy + ? (_isSignUp ? 'Creating…' : 'Signing in…') + : (_isSignUp ? 'Create account' : 'Sign in'), + ), ), ], ), diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index c5a04b7f..81503d05 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -308,6 +308,7 @@ class _FolderNavigatorState extends ConsumerState { children: [ if (isSharedWithMe) ShadButton( + key: const ValueKey('cloud-add-shared-item'), onPressed: () => showAddSharedItemDialog(context), leading: const Icon(LucideIcons.link), child: const Text('Add by Link or Code'), @@ -385,6 +386,11 @@ class _FolderNavigatorState extends ConsumerState { ), ), ShadButton.secondary( + key: ValueKey( + isCloudWorkspace + ? 'cloud-add-folder' + : 'local-add-folder', + ), leading: const Icon(LucideIcons.folderPlus), onPressed: isCommunityWorkspace ? null @@ -399,6 +405,11 @@ class _FolderNavigatorState extends ConsumerState { child: const Text('Add Folder'), ), ShadButton( + key: ValueKey( + isCloudWorkspace + ? 'cloud-create-strategy' + : 'local-create-strategy', + ), onPressed: isCommunityWorkspace ? null : showCreateDialog, leading: const Icon(Icons.add), child: Text( @@ -492,15 +503,19 @@ class _LibraryNavigationRailState extends ConsumerState { final items = [ _LibraryRailItemData( + key: const ValueKey('library-local'), icon: LucideIcons.monitor, label: 'This Computer', + semanticsLabel: 'This Computer library', description: 'Local strategies and imports', selected: workspace == LibraryWorkspace.local, onTap: () => _selectLocal(), ), _LibraryRailItemData( + key: const ValueKey('library-cloud'), icon: LucideIcons.cloud, label: 'Cloud', + semanticsLabel: 'Cloud library', description: cloudAvailable ? 'Your online strategies' : 'Log in to sync strategies', @@ -509,8 +524,10 @@ class _LibraryNavigationRailState extends ConsumerState { onTap: cloudAvailable ? () => _selectCloudHome() : null, ), _LibraryRailItemData( + key: const ValueKey('library-shared'), icon: LucideIcons.users, label: 'Shared', + semanticsLabel: 'Shared library', description: cloudAvailable ? 'Strategies shared with you' : 'Log in to view shared strats', @@ -519,8 +536,10 @@ class _LibraryNavigationRailState extends ConsumerState { onTap: cloudAvailable ? () => _selectShared() : null, ), _LibraryRailItemData( + key: const ValueKey('library-community'), icon: Icons.public, label: 'Community', + semanticsLabel: 'Community library', description: 'Public strategy library', selected: workspace == LibraryWorkspace.community, onTap: () => _selectCommunity(), @@ -601,6 +620,7 @@ class _LibraryNavigationRailState extends ConsumerState { children: [ for (final item in items) ...[ _LibraryRailItem( + key: item.key, data: item, expanded: _expanded, showDetails: _showExpandedContent, @@ -609,6 +629,7 @@ class _LibraryNavigationRailState extends ConsumerState { ], const Spacer(), _AccountRailItem( + key: const ValueKey('library-account-action'), expanded: _expanded, showDetails: _showExpandedContent, isLoading: authState.isLoading, @@ -617,6 +638,9 @@ class _LibraryNavigationRailState extends ConsumerState { label: authState.isAuthenticated ? authState.displayName : 'Log In', + semanticsLabel: authState.isAuthenticated + ? 'Account for ${authState.displayName}' + : 'Log in to Icarus', onAuthAction: authState.isLoading ? null : () async { @@ -750,15 +774,19 @@ class _RailHeader extends StatelessWidget { class _LibraryRailItemData { const _LibraryRailItemData({ + required this.key, required this.icon, required this.label, + required this.semanticsLabel, required this.description, required this.selected, required this.onTap, }); + final Key key; final IconData icon; final String label; + final String semanticsLabel; final String description; final bool selected; final VoidCallback? onTap; @@ -766,6 +794,7 @@ class _LibraryRailItemData { class _LibraryRailItem extends StatelessWidget { const _LibraryRailItem({ + super.key, required this.data, required this.expanded, required this.showDetails, @@ -779,79 +808,86 @@ class _LibraryRailItem extends StatelessWidget { Widget build(BuildContext context) { final selectedColor = Settings.tacticalVioletTheme.primary.withValues(alpha: 0.18); - return Tooltip( - message: data.description, - waitDuration: const Duration(milliseconds: 500), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(10), - mouseCursor: data.onTap == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - onTap: data.onTap, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 140), - opacity: data.onTap == null ? 0.55 : 1, - child: AnimatedContainer( + return Semantics( + label: data.semanticsLabel, + button: true, + enabled: data.onTap != null, + selected: data.selected, + excludeSemantics: true, + child: Tooltip( + message: data.description, + waitDuration: const Duration(milliseconds: 500), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + mouseCursor: data.onTap == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: data.onTap, + child: AnimatedOpacity( duration: const Duration(milliseconds: 140), - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: data.selected ? selectedColor : Colors.transparent, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: data.selected - ? Settings.tacticalVioletTheme.primary - : Colors.transparent, + opacity: data.onTap == null ? 0.55 : 1, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: data.selected ? selectedColor : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: data.selected + ? Settings.tacticalVioletTheme.primary + : Colors.transparent, + ), ), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final showLabel = showDetails && constraints.maxWidth >= 96; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 26, - child: Align( - alignment: Alignment.center, - child: Icon( - data.icon, - size: 18, - color: data.onTap == null - ? Settings.tacticalVioletTheme.mutedForeground - : null, + child: LayoutBuilder( + builder: (context, constraints) { + final showLabel = showDetails && constraints.maxWidth >= 96; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 26, + child: Align( + alignment: Alignment.center, + child: Icon( + data.icon, + size: 18, + color: data.onTap == null + ? Settings.tacticalVioletTheme.mutedForeground + : null, + ), ), ), - ), - Positioned.fill( - left: 33, - child: IgnorePointer( - ignoring: !showLabel, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: expanded && showLabel ? 1 : 0, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - data.label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, + Positioned.fill( + left: 33, + child: IgnorePointer( + ignoring: !showLabel, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: expanded && showLabel ? 1 : 0, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + data.label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), ), ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ), ), @@ -863,12 +899,14 @@ class _LibraryRailItem extends StatelessWidget { class _AccountRailItem extends StatelessWidget { const _AccountRailItem({ + super.key, required this.expanded, required this.showDetails, required this.isLoading, required this.isAuthenticated, required this.avatarUrl, required this.label, + required this.semanticsLabel, required this.onAuthAction, }); @@ -878,78 +916,85 @@ class _AccountRailItem extends StatelessWidget { final bool isAuthenticated; final String? avatarUrl; final String label; + final String semanticsLabel; final VoidCallback? onAuthAction; @override Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(10), - mouseCursor: onAuthAction == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - onTap: onAuthAction, - child: AnimatedContainer( - duration: const Duration(milliseconds: 140), - curve: Curves.easeOutCubic, - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.secondary.withValues( - alpha: 0.5, + return Semantics( + label: semanticsLabel, + button: true, + enabled: onAuthAction != null, + excludeSemantics: true, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + mouseCursor: onAuthAction == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: onAuthAction, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.secondary.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Settings.tacticalVioletTheme.border), ), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Settings.tacticalVioletTheme.border), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final showLabel = showDetails && constraints.maxWidth >= 96; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 28, - child: Align( - alignment: Alignment.center, - child: _AccountAvatar( - avatarUrl: avatarUrl, - isAuthenticated: isAuthenticated, + child: LayoutBuilder( + builder: (context, constraints) { + final showLabel = showDetails && constraints.maxWidth >= 96; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 28, + child: Align( + alignment: Alignment.center, + child: _AccountAvatar( + avatarUrl: avatarUrl, + isAuthenticated: isAuthenticated, + ), ), ), - ), - Positioned.fill( - left: 38, - child: IgnorePointer( - ignoring: !showLabel, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, - opacity: expanded && showLabel ? 1 : 0, - child: showLabel - ? Row( - children: [ - Expanded( - child: Text( - isLoading ? 'Please wait...' : label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w700, + Positioned.fill( + left: 38, + child: IgnorePointer( + ignoring: !showLabel, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + opacity: expanded && showLabel ? 1 : 0, + child: showLabel + ? Row( + children: [ + Expanded( + child: Text( + isLoading ? 'Please wait...' : label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), ), ), - ), - ], - ) - : const SizedBox.shrink(), + ], + ) + : const SizedBox.shrink(), + ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ), ), diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart new file mode 100644 index 00000000..4da669e4 --- /dev/null +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; +import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('auth dialog exposes stable fields and actions', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_testApp(const AuthDialog())); + + expect(find.byKey(const ValueKey('auth-email-field')), findsOneWidget); + expect(find.byKey(const ValueKey('auth-password-field')), findsOneWidget); + expect(_semanticsLabel('Email'), findsOneWidget); + expect(_semanticsLabel('Password'), findsOneWidget); + expect(find.byKey(const ValueKey('auth-mode-switch')), findsOneWidget); + expect(find.byKey(const ValueKey('auth-discord-button')), findsOneWidget); + expect(find.byKey(const ValueKey('auth-submit-button')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('auth-mode-switch'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('auth-confirm-password-field')), + findsOneWidget, + ); + expect(_semanticsLabel('Confirm password'), findsOneWidget); + expect(find.text('Create account'), findsAtLeastNWidgets(1)); + }); + + testWidgets('library rail exposes stable destinations while signed out', + (tester) async { + await tester.pumpWidget( + _testApp( + const SizedBox( + width: 220, + height: 800, + child: LibraryNavigationRail(), + ), + ), + ); + + expect(find.byKey(const ValueKey('library-local')), findsOneWidget); + expect(find.byKey(const ValueKey('library-cloud')), findsOneWidget); + expect(find.byKey(const ValueKey('library-shared')), findsOneWidget); + expect(find.byKey(const ValueKey('library-community')), findsOneWidget); + expect( + find.byKey(const ValueKey('library-account-action')), + findsOneWidget, + ); + expect(_semanticsLabel('This Computer library'), findsOneWidget); + expect(_semanticsLabel('Cloud library'), findsOneWidget); + expect(_semanticsLabel('Shared library'), findsOneWidget); + expect(_semanticsLabel('Community library'), findsOneWidget); + expect(_semanticsLabel('Log in to Icarus'), findsOneWidget); + }); +} + +Finder _semanticsLabel(String label) { + return find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == label, + ); +} + +Widget _testApp(Widget child) { + return ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedOutAuthProvider.new), + ], + child: ShadApp( + home: Scaffold(body: child), + ), + ); +} + +class _SignedOutAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, + ); +} From e64bd859ca8197d3698cb8c81d1f7c23b982ed09 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:41:55 -0400 Subject: [PATCH 2/3] Expose rail activation to assistive technology --- lib/widgets/folder_navigator.dart | 2 ++ test/widgets/cloud_beta_automation_semantics_test.dart | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 81503d05..22387b9b 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -813,6 +813,7 @@ class _LibraryRailItem extends StatelessWidget { button: true, enabled: data.onTap != null, selected: data.selected, + onTap: data.onTap, excludeSemantics: true, child: Tooltip( message: data.description, @@ -925,6 +926,7 @@ class _AccountRailItem extends StatelessWidget { label: semanticsLabel, button: true, enabled: onAuthAction != null, + onTap: onAuthAction, excludeSemantics: true, child: Material( color: Colors.transparent, diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index 4da669e4..c87a75f4 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -59,9 +59,16 @@ void main() { expect(_semanticsLabel('Shared library'), findsOneWidget); expect(_semanticsLabel('Community library'), findsOneWidget); expect(_semanticsLabel('Log in to Icarus'), findsOneWidget); + expect(_semantics('This Computer library').properties.onTap, isNotNull); + expect(_semantics('Community library').properties.onTap, isNotNull); + expect(_semantics('Log in to Icarus').properties.onTap, isNotNull); }); } +Semantics _semantics(String label) { + return _semanticsLabel(label).evaluate().single.widget as Semantics; +} + Finder _semanticsLabel(String label) { return find.byWidgetPredicate( (widget) => widget is Semantics && widget.properties.label == label, From 89c81fb6ac83106a87809350c324fd24a882ab78 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:53:32 -0400 Subject: [PATCH 3/3] Merge auth field semantics into editable controls --- lib/widgets/dialogs/auth/auth_dialog.dart | 75 ++++++++++--------- .../cloud_beta_automation_semantics_test.dart | 30 ++++++++ 2 files changed, 69 insertions(+), 36 deletions(-) diff --git a/lib/widgets/dialogs/auth/auth_dialog.dart b/lib/widgets/dialogs/auth/auth_dialog.dart index 13d806d0..f4a9182d 100644 --- a/lib/widgets/dialogs/auth/auth_dialog.dart +++ b/lib/widgets/dialogs/auth/auth_dialog.dart @@ -158,49 +158,52 @@ class _AuthDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Semantics( - key: const ValueKey('auth-email-field'), - label: 'Email', - textField: true, - child: CustomTextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.email], - hintText: 'Email', - textInputAction: TextInputAction.next, - hasError: _errorField == _AuthField.email, + MergeSemantics( + child: Semantics( + key: const ValueKey('auth-email-field'), + label: 'Email', + child: CustomTextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + hintText: 'Email', + textInputAction: TextInputAction.next, + hasError: _errorField == _AuthField.email, + ), ), ), const SizedBox(height: 10), - Semantics( - key: const ValueKey('auth-password-field'), - label: 'Password', - textField: true, - child: CustomTextField( - controller: _passwordController, - obscureText: true, - autofillHints: const [AutofillHints.password], - hintText: 'Password', - textInputAction: - _isSignUp ? TextInputAction.next : TextInputAction.done, - onSubmitted: _isSignUp ? null : (_) => _submit(), - hasError: _errorField == _AuthField.password, + MergeSemantics( + child: Semantics( + key: const ValueKey('auth-password-field'), + label: 'Password', + child: CustomTextField( + controller: _passwordController, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Password', + textInputAction: + _isSignUp ? TextInputAction.next : TextInputAction.done, + onSubmitted: _isSignUp ? null : (_) => _submit(), + hasError: _errorField == _AuthField.password, + ), ), ), if (_isSignUp) ...[ const SizedBox(height: 10), - Semantics( - key: const ValueKey('auth-confirm-password-field'), - label: 'Confirm password', - textField: true, - child: CustomTextField( - controller: _confirmController, - obscureText: true, - autofillHints: const [AutofillHints.password], - hintText: 'Confirm password', - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - hasError: _errorField == _AuthField.confirm, + MergeSemantics( + child: Semantics( + key: const ValueKey('auth-confirm-password-field'), + label: 'Confirm password', + child: CustomTextField( + controller: _confirmController, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Confirm password', + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + hasError: _errorField == _AuthField.confirm, + ), ), ), ], diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index c87a75f4..6efe6de2 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/providers/auth_provider.dart'; @@ -8,6 +9,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; void main() { testWidgets('auth dialog exposes stable fields and actions', (tester) async { + final semanticsHandle = tester.ensureSemantics(); tester.view.physicalSize = const Size(1280, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -22,6 +24,11 @@ void main() { expect(find.byKey(const ValueKey('auth-mode-switch')), findsOneWidget); expect(find.byKey(const ValueKey('auth-discord-button')), findsOneWidget); expect(find.byKey(const ValueKey('auth-submit-button')), findsOneWidget); + expect(_textFieldNodes(tester), hasLength(2)); + expect( + tester.getSemantics(find.byKey(const ValueKey('auth-email-field'))).label, + 'Email', + ); await tester.tap(find.byKey(const ValueKey('auth-mode-switch'))); await tester.pumpAndSettle(); @@ -32,6 +39,8 @@ void main() { ); expect(_semanticsLabel('Confirm password'), findsOneWidget); expect(find.text('Create account'), findsAtLeastNWidgets(1)); + expect(_textFieldNodes(tester), hasLength(3)); + semanticsHandle.dispose(); }); testWidgets('library rail exposes stable destinations while signed out', @@ -69,6 +78,27 @@ Semantics _semantics(String label) { return _semanticsLabel(label).evaluate().single.widget as Semantics; } +List _textFieldNodes(WidgetTester tester) { + final nodes = []; + + void visit(SemanticsNode node) { + if (node.flagsCollection.isTextField) { + nodes.add(node); + } + node.visitChildren((child) { + visit(child); + return true; + }); + } + + final root = tester + .binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode; + if (root != null) { + visit(root); + } + return nodes; +} + Finder _semanticsLabel(String label) { return find.byWidgetPredicate( (widget) => widget is Semantics && widget.properties.label == label,