diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d8b3c..98e81ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-01-05 + +### Added +- **Semantic labels for UI automation** (`lib/core/semantics/`) + - `semantic_ids.dart` - Centralized semantic identifier constants + - `semantic_widget.dart` - Helper widget and extension for adding semantics + - Enables browser automation tools (Puppeteer, WebDriver) via accessibility tree +- Semantic IDs added to: + - Profile dropdown button and menu items (theme options, settings, logout) + - Room navigation tabs (Front Hall, Control Room, Security, Parlor) + - NavPanel items (sidebar navigation) +- `SemanticsBinding.instance.ensureSemantics()` enabled on web builds + ## [1.1.16] - 2026-01-05 ### Fixed diff --git a/lib/core/semantics/semantic_ids.dart b/lib/core/semantics/semantic_ids.dart new file mode 100644 index 0000000..e2fb12b --- /dev/null +++ b/lib/core/semantics/semantic_ids.dart @@ -0,0 +1,114 @@ +/// Semantic identifiers for UI automation and accessibility. +/// +/// These IDs are exposed via Flutter's Semantics tree, making widgets +/// discoverable by automation tools (Appium, WebDriver, Puppeteer with +/// accessibility enabled). +/// +/// Naming convention: `{area}_{component}_{identifier}` +/// - area: room or feature area (e.g., profile, nav, dataGrid) +/// - component: widget type (e.g., menu, button, row) +/// - identifier: specific item (e.g., light, containers, selectAll) +library; + +/// Profile dropdown menu IDs. +abstract class ProfileSemantics { + static const button = 'profile_button'; + static const menu = 'profile_menu'; + static const settings = 'profile_menu_settings'; + static const themeSystem = 'profile_menu_theme_system'; + static const themeLight = 'profile_menu_theme_light'; + static const themeDark = 'profile_menu_theme_dark'; + static const logout = 'profile_menu_logout'; +} + +/// Room tab navigation IDs. +abstract class RoomTabSemantics { + static const frontHall = 'roomTab_frontHall'; + static const controlRoom = 'roomTab_controlRoom'; + static const security = 'roomTab_security'; + static const parlor = 'roomTab_parlor'; + + /// Get semantic ID for room index. + static String forIndex(int index) => switch (index) { + 0 => frontHall, + 1 => controlRoom, + 2 => security, + 3 => parlor, + _ => 'roomTab_$index', + }; +} + +/// Navigation panel IDs. +abstract class NavSemantics { + static const panel = 'nav_panel'; + static const refresh = 'nav_refresh'; + + /// Generate ID for a nav item. + static String item(String id) => 'nav_item_$id'; + + /// Generate ID for a nav section. + static String section(String id) => 'nav_section_$id'; +} + +/// DataGrid component IDs. +abstract class DataGridSemantics { + static const grid = 'dataGrid'; + static const search = 'dataGrid_search'; + static const searchClear = 'dataGrid_search_clear'; + static const selectAll = 'dataGrid_selectAll'; + static const loading = 'dataGrid_loading'; + static const empty = 'dataGrid_empty'; + static const error = 'dataGrid_error'; + static const refresh = 'dataGrid_refresh'; + + /// Generate ID for a column header. + static String header(String columnId) => 'dataGrid_header_$columnId'; + + /// Generate ID for a row. + static String row(String itemId) => 'dataGrid_row_$itemId'; + + /// Generate ID for a row checkbox. + static String rowCheckbox(String itemId) => 'dataGrid_row_${itemId}_checkbox'; + + /// Generate ID for a row actions menu. + static String rowActions(String itemId) => 'dataGrid_row_${itemId}_actions'; + + /// Generate ID for a row action. + static String rowAction(String itemId, String actionId) => + 'dataGrid_row_${itemId}_action_$actionId'; + + /// Generate ID for a bulk action button. + static String bulkAction(String actionId) => 'dataGrid_bulk_$actionId'; + + static const bulkClear = 'dataGrid_bulk_clear'; +} + +/// Dialog/modal IDs. +abstract class DialogSemantics { + static const confirm = 'dialog_confirm'; + static const cancel = 'dialog_cancel'; + static const close = 'dialog_close'; + + /// Generate ID for a named dialog. + static String named(String name) => 'dialog_$name'; + + /// Generate ID for a dialog action button. + static String action(String dialogName, String actionId) => + 'dialog_${dialogName}_$actionId'; +} + +/// Settings page IDs. +abstract class SettingsSemantics { + static const themeDropdown = 'settings_theme'; + static const defaultRoomDropdown = 'settings_defaultRoom'; +} + +/// Loading/state indicator IDs. +abstract class StateSemantics { + static const authLoading = 'state_auth_loading'; + static const authError = 'state_auth_error'; + static const pageLoading = 'state_page_loading'; + + /// Generate ID for a snackbar. + static String snackbar(String type) => 'snackbar_$type'; +} diff --git a/lib/core/semantics/semantic_widget.dart b/lib/core/semantics/semantic_widget.dart new file mode 100644 index 0000000..1f79775 --- /dev/null +++ b/lib/core/semantics/semantic_widget.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +/// Wraps a widget with semantic information for accessibility and automation. +/// +/// Usage: +/// ```dart +/// SemanticWidget( +/// id: ProfileSemantics.button, +/// label: 'Open profile menu', +/// child: IconButton(...), +/// ) +/// ``` +/// +/// For buttons, use `button: true`. For other interactive elements, +/// set the appropriate semantic properties. +class SemanticWidget extends StatelessWidget { + const SemanticWidget({ + super.key, + required this.id, + required this.child, + this.label, + this.hint, + this.button = false, + this.link = false, + this.header = false, + this.textField = false, + this.enabled = true, + this.selected, + this.checked, + this.value, + this.excludeSemantics = false, + }); + + /// Unique identifier for this widget, exposed via [SemanticsProperties.identifier]. + final String id; + + /// The widget to wrap. + final Widget child; + + /// Accessibility label describing the widget. + final String? label; + + /// Hint text for screen readers. + final String? hint; + + /// Whether this widget represents a button. + final bool button; + + /// Whether this widget represents a link. + final bool link; + + /// Whether this widget represents a header. + final bool header; + + /// Whether this widget represents a text field. + final bool textField; + + /// Whether the widget is enabled. + final bool enabled; + + /// Whether the widget is selected (for toggle buttons, tabs). + final bool? selected; + + /// Whether the widget is checked (for checkboxes). + final bool? checked; + + /// Current value (for sliders, progress indicators). + final String? value; + + /// Whether to exclude child semantics. + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return Semantics( + identifier: id, + label: label, + hint: hint, + button: button, + link: link, + header: header, + textField: textField, + enabled: enabled, + selected: selected, + checked: checked, + value: value, + excludeSemantics: excludeSemantics, + child: child, + ); + } +} + +/// Extension to easily wrap any widget with semantic info. +extension SemanticExtension on Widget { + /// Wraps this widget with a semantic identifier. + Widget withSemantics({ + required String id, + String? label, + String? hint, + bool button = false, + bool link = false, + bool enabled = true, + bool? selected, + bool? checked, + }) { + return SemanticWidget( + id: id, + label: label, + hint: hint, + button: button, + link: link, + enabled: enabled, + selected: selected, + checked: checked, + child: this, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 05d0958..6ff9864 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ import 'dart:developer' as developer; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app.dart'; @@ -11,6 +13,12 @@ import 'version.g.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Enable semantics tree on web for accessibility and automation tools. + // This exposes widget identifiers to browser automation (Puppeteer, etc.) + if (kIsWeb) { + SemanticsBinding.instance.ensureSemantics(); + } + // Use path-based URLs on web (no-op on mobile/desktop) configureUrlStrategy(); diff --git a/lib/shared/layouts/widgets/nav_panel.dart b/lib/shared/layouts/widgets/nav_panel.dart index 9f2227e..9db4c60 100644 --- a/lib/shared/layouts/widgets/nav_panel.dart +++ b/lib/shared/layouts/widgets/nav_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:tatlock_ui/core/semantics/semantic_ids.dart'; import 'panel_header.dart'; @@ -199,17 +200,22 @@ class _NavTile extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: Material( - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.4) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: onTap, + return Semantics( + identifier: NavSemantics.item(item.id), + label: item.label, + button: true, + selected: isSelected, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Material( + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.4) + : Colors.transparent, borderRadius: BorderRadius.circular(8), - child: Padding( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ @@ -258,6 +264,7 @@ class _NavTile extends StatelessWidget { ), ), ), + ), ); } } diff --git a/lib/shared/layouts/widgets/profile_dropdown.dart b/lib/shared/layouts/widgets/profile_dropdown.dart index 5e8950b..da080ba 100644 --- a/lib/shared/layouts/widgets/profile_dropdown.dart +++ b/lib/shared/layouts/widgets/profile_dropdown.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tatlock_ui/core/auth/auth_provider.dart'; +import 'package:tatlock_ui/core/semantics/semantic_ids.dart'; import 'package:tatlock_ui/core/theme/theme_provider.dart'; import 'package:tatlock_ui/routing/app_router.dart'; @@ -20,189 +21,209 @@ class ProfileDropdown extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; return authState.when( - data: (auth) => PopupMenuButton( - offset: const Offset(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: Tooltip( - message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest', - child: CircleAvatar( - radius: 18, - backgroundColor: colorScheme.primaryContainer, - child: auth.isAuthenticated - ? Text( - _getInitials(auth.userName), - style: TextStyle( + data: (auth) => Semantics( + identifier: ProfileSemantics.button, + label: 'Profile menu', + button: true, + child: PopupMenuButton( + offset: const Offset(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Tooltip( + message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest', + child: CircleAvatar( + radius: 18, + backgroundColor: colorScheme.primaryContainer, + child: auth.isAuthenticated + ? Text( + _getInitials(auth.userName), + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ) + : Icon( + Icons.person_outline, + size: 20, color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, ), - ) - : Icon( - Icons.person_outline, - size: 20, - color: colorScheme.onPrimaryContainer, - ), - ), - ), - itemBuilder: (context) => [ - // User info header (non-selectable) - PopupMenuItem( - enabled: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest', - style: Theme.of(context).textTheme.titleSmall, - ), - if (auth.userEmail != null) - Text( - auth.userEmail!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], ), ), - const PopupMenuDivider(), - - // Settings - const PopupMenuItem( - value: 'settings', - child: Row( - children: [ - Icon(Icons.settings_outlined, size: 20), - SizedBox(width: 12), - Text('Settings'), - ], - ), - ), - - // Theme submenu header - PopupMenuItem( - enabled: false, - height: 32, - child: Text( - 'THEME', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - ), - - // Theme: System - PopupMenuItem( - value: 'theme_system', - height: 40, - child: Row( - children: [ - Icon( - Icons.brightness_auto, - size: 18, - color: currentTheme == ThemeSetting.system - ? colorScheme.primary - : null, - ), - const SizedBox(width: 12), - Text( - 'System', - style: currentTheme == ThemeSetting.system - ? TextStyle(color: colorScheme.primary) - : null, - ), - const Spacer(), - if (currentTheme == ThemeSetting.system) - Icon(Icons.check, size: 16, color: colorScheme.primary), - ], - ), - ), - - // Theme: Light - PopupMenuItem( - value: 'theme_light', - height: 40, - child: Row( - children: [ - Icon( - Icons.light_mode, - size: 18, - color: currentTheme == ThemeSetting.light - ? colorScheme.primary - : null, - ), - const SizedBox(width: 12), - Text( - 'Light', - style: currentTheme == ThemeSetting.light - ? TextStyle(color: colorScheme.primary) - : null, - ), - const Spacer(), - if (currentTheme == ThemeSetting.light) - Icon(Icons.check, size: 16, color: colorScheme.primary), - ], - ), - ), - - // Theme: Dark - PopupMenuItem( - value: 'theme_dark', - height: 40, - child: Row( - children: [ - Icon( - Icons.dark_mode, - size: 18, - color: currentTheme == ThemeSetting.dark - ? colorScheme.primary - : null, - ), - const SizedBox(width: 12), - Text( - 'Dark', - style: currentTheme == ThemeSetting.dark - ? TextStyle(color: colorScheme.primary) - : null, - ), - const Spacer(), - if (currentTheme == ThemeSetting.dark) - Icon(Icons.check, size: 16, color: colorScheme.primary), - ], - ), - ), - - const PopupMenuDivider(), - - // Logout (only if authenticated) - if (auth.isAuthenticated) - const PopupMenuItem( - value: 'logout', - child: Row( + itemBuilder: (context) => [ + // User info header (non-selectable) + PopupMenuItem( + enabled: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.logout, size: 20), - SizedBox(width: 12), - Text('Logout'), + Text( + auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest', + style: Theme.of(context).textTheme.titleSmall, + ), + if (auth.userEmail != null) + Text( + auth.userEmail!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ], ), ), - ], - onSelected: (value) { - switch (value) { - case 'settings': - context.go(AppRoutes.settings); - case 'theme_system': - _updateTheme(ref, ThemeSetting.system); - case 'theme_light': - _updateTheme(ref, ThemeSetting.light); - case 'theme_dark': - _updateTheme(ref, ThemeSetting.dark); - case 'logout': - ref.read(authProvider.notifier).signOut(); - } - }, + const PopupMenuDivider(), + + // Settings + PopupMenuItem( + value: 'settings', + child: Semantics( + identifier: ProfileSemantics.settings, + child: const Row( + children: [ + Icon(Icons.settings_outlined, size: 20), + SizedBox(width: 12), + Text('Settings'), + ], + ), + ), + ), + + // Theme submenu header + PopupMenuItem( + enabled: false, + height: 32, + child: Text( + 'THEME', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ), + + // Theme: System + PopupMenuItem( + value: 'theme_system', + height: 40, + child: Semantics( + identifier: ProfileSemantics.themeSystem, + child: Row( + children: [ + Icon( + Icons.brightness_auto, + size: 18, + color: currentTheme == ThemeSetting.system + ? colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text( + 'System', + style: currentTheme == ThemeSetting.system + ? TextStyle(color: colorScheme.primary) + : null, + ), + const Spacer(), + if (currentTheme == ThemeSetting.system) + Icon(Icons.check, size: 16, color: colorScheme.primary), + ], + ), + ), + ), + + // Theme: Light + PopupMenuItem( + value: 'theme_light', + height: 40, + child: Semantics( + identifier: ProfileSemantics.themeLight, + child: Row( + children: [ + Icon( + Icons.light_mode, + size: 18, + color: currentTheme == ThemeSetting.light + ? colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text( + 'Light', + style: currentTheme == ThemeSetting.light + ? TextStyle(color: colorScheme.primary) + : null, + ), + const Spacer(), + if (currentTheme == ThemeSetting.light) + Icon(Icons.check, size: 16, color: colorScheme.primary), + ], + ), + ), + ), + + // Theme: Dark + PopupMenuItem( + value: 'theme_dark', + height: 40, + child: Semantics( + identifier: ProfileSemantics.themeDark, + child: Row( + children: [ + Icon( + Icons.dark_mode, + size: 18, + color: currentTheme == ThemeSetting.dark + ? colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text( + 'Dark', + style: currentTheme == ThemeSetting.dark + ? TextStyle(color: colorScheme.primary) + : null, + ), + const Spacer(), + if (currentTheme == ThemeSetting.dark) + Icon(Icons.check, size: 16, color: colorScheme.primary), + ], + ), + ), + ), + + const PopupMenuDivider(), + + // Logout (only if authenticated) + if (auth.isAuthenticated) + PopupMenuItem( + value: 'logout', + child: Semantics( + identifier: ProfileSemantics.logout, + child: const Row( + children: [ + Icon(Icons.logout, size: 20), + SizedBox(width: 12), + Text('Logout'), + ], + ), + ), + ), + ], + onSelected: (value) { + switch (value) { + case 'settings': + context.go(AppRoutes.settings); + case 'theme_system': + _updateTheme(ref, ThemeSetting.system); + case 'theme_light': + _updateTheme(ref, ThemeSetting.light); + case 'theme_dark': + _updateTheme(ref, ThemeSetting.dark); + case 'logout': + ref.read(authProvider.notifier).signOut(); + } + }, + ), ), loading: () => const CircleAvatar( radius: 18, diff --git a/lib/shared/layouts/widgets/top_header_bar.dart b/lib/shared/layouts/widgets/top_header_bar.dart index ab8ca0b..65f5e34 100644 --- a/lib/shared/layouts/widgets/top_header_bar.dart +++ b/lib/shared/layouts/widgets/top_header_bar.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:tatlock_ui/core/semantics/semantic_ids.dart'; import 'profile_dropdown.dart'; @@ -127,17 +128,23 @@ class TopHeaderBar extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: Tooltip( - message: room.label, - child: IconButton( - icon: Icon( - isSelected ? room.selectedIcon : room.icon, - color: isSelected ? colorScheme.primary : colorScheme.onSurface, - ), - onPressed: () => onRoomSelected(index), - style: IconButton.styleFrom( - backgroundColor: - isSelected ? colorScheme.primaryContainer : null, + child: Semantics( + identifier: RoomTabSemantics.forIndex(index), + label: room.label, + button: true, + selected: isSelected, + child: Tooltip( + message: room.label, + child: IconButton( + icon: Icon( + isSelected ? room.selectedIcon : room.icon, + color: isSelected ? colorScheme.primary : colorScheme.onSurface, + ), + onPressed: () => onRoomSelected(index), + style: IconButton.styleFrom( + backgroundColor: + isSelected ? colorScheme.primaryContainer : null, + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 493db91..3fd3657 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.16+1 +version: 1.2.0+1 environment: sdk: ^3.10.4