Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c51f757a | ||
|
|
04032a6dbc | ||
|
|
6b6614f482 |
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
- Theme toggle causing logout due to AuthInterceptor auto-signout on 401
|
||||||
|
- Removed aggressive `signOut()` call in `AuthInterceptor.onError`
|
||||||
|
- 401 errors now propagate to calling code for graceful handling
|
||||||
|
- Preferences API 401 no longer triggers full logout redirect
|
||||||
|
|
||||||
|
## [1.1.15] - 2026-01-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Theme toggle causing auth issues due to AuthNotifier auto-dispose
|
||||||
|
- Applied `@persistentRiverpod` annotation to AuthNotifier
|
||||||
|
- AuthProvider now persists for app lifetime, preventing rebuild on theme change
|
||||||
|
|
||||||
## [1.1.14] - 2026-01-04
|
## [1.1.14] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -447,13 +447,13 @@ Dio coreApiClient(Ref ref) { ... }
|
|||||||
|
|
||||||
**When to use `@persistentRiverpod`:**
|
**When to use `@persistentRiverpod`:**
|
||||||
|
|
||||||
| Use Case | Annotation |
|
| Use Case | Annotation | Example |
|
||||||
|----------|------------|
|
|----------|------------|---------|
|
||||||
| API clients with interceptors | `@persistentRiverpod` |
|
| API clients with interceptors | `@persistentRiverpod` | `coreApiClient`, `tatlockApiClient` |
|
||||||
| Theme/config providers | `@persistentRiverpod` |
|
| Auth state provider | `@persistentRiverpod` | `AuthNotifier` |
|
||||||
| Auth state provider | `@persistentRiverpod` |
|
| Theme/config providers | `@persistentRiverpod` | `ThemeNotifier` |
|
||||||
| Feature data providers | `@riverpod` (default) |
|
| Feature data providers | `@riverpod` (default) | `ContainersNotifier` |
|
||||||
| UI state providers | `@riverpod` (default) |
|
| UI state providers | `@riverpod` (default) | `SearchFilterNotifier` |
|
||||||
|
|
||||||
## File Naming Conventions
|
## File Naming Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -38,16 +38,9 @@ class AuthInterceptor extends Interceptor {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
// Skip auth error handling for LAN development
|
// Don't auto-signout on 401 - let calling code handle auth errors gracefully.
|
||||||
if (!AppConfig.requiresAuth) {
|
// Auto-signout was causing issues (e.g., theme toggle triggering logout when
|
||||||
handler.next(err);
|
// preferences API returned 401).
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.response?.statusCode == 401) {
|
|
||||||
// Token expired - trigger re-authentication
|
|
||||||
_ref.read(authProvider.notifier).signOut();
|
|
||||||
}
|
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
|
import '../providers/annotations.dart';
|
||||||
import 'auth_datasource.dart';
|
import 'auth_datasource.dart';
|
||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
import 'oidc_service.dart';
|
import 'oidc_service.dart';
|
||||||
@@ -24,7 +25,7 @@ part 'auth_provider.g.dart';
|
|||||||
///
|
///
|
||||||
/// After OIDC authentication, syncs with core-api via POST /auth/sync
|
/// After OIDC authentication, syncs with core-api via POST /auth/sync
|
||||||
/// to get user profile, roles, and preferences.
|
/// to get user profile, roles, and preferences.
|
||||||
@riverpod
|
@persistentRiverpod
|
||||||
class AuthNotifier extends _$AuthNotifier {
|
class AuthNotifier extends _$AuthNotifier {
|
||||||
// Storage keys
|
// Storage keys
|
||||||
static const _accessTokenKey = 'auth_access_token';
|
static const _accessTokenKey = 'auth_access_token';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
@@ -11,6 +13,12 @@ import 'version.g.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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)
|
// Use path-based URLs on web (no-op on mobile/desktop)
|
||||||
configureUrlStrategy();
|
configureUrlStrategy();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
||||||
|
|
||||||
import 'panel_header.dart';
|
import 'panel_header.dart';
|
||||||
|
|
||||||
@@ -199,17 +200,22 @@ class _NavTile extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Padding(
|
return Semantics(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
identifier: NavSemantics.item(item.id),
|
||||||
child: Material(
|
label: item.label,
|
||||||
color: isSelected
|
button: true,
|
||||||
? colorScheme.primaryContainer.withValues(alpha: 0.4)
|
selected: isSelected,
|
||||||
: Colors.transparent,
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(8),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
child: InkWell(
|
child: Material(
|
||||||
onTap: onTap,
|
color: isSelected
|
||||||
|
? colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||||
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -258,6 +264,7 @@ class _NavTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:tatlock_ui/core/auth/auth_provider.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/core/theme/theme_provider.dart';
|
||||||
import 'package:tatlock_ui/routing/app_router.dart';
|
import 'package:tatlock_ui/routing/app_router.dart';
|
||||||
|
|
||||||
@@ -20,189 +21,209 @@ class ProfileDropdown extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return authState.when(
|
return authState.when(
|
||||||
data: (auth) => PopupMenuButton<String>(
|
data: (auth) => Semantics(
|
||||||
offset: const Offset(0, 48),
|
identifier: ProfileSemantics.button,
|
||||||
shape: RoundedRectangleBorder(
|
label: 'Profile menu',
|
||||||
borderRadius: BorderRadius.circular(8),
|
button: true,
|
||||||
),
|
child: PopupMenuButton<String>(
|
||||||
child: Tooltip(
|
offset: const Offset(0, 48),
|
||||||
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
|
shape: RoundedRectangleBorder(
|
||||||
child: CircleAvatar(
|
borderRadius: BorderRadius.circular(8),
|
||||||
radius: 18,
|
),
|
||||||
backgroundColor: colorScheme.primaryContainer,
|
child: Tooltip(
|
||||||
child: auth.isAuthenticated
|
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
|
||||||
? Text(
|
child: CircleAvatar(
|
||||||
_getInitials(auth.userName),
|
radius: 18,
|
||||||
style: TextStyle(
|
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,
|
color: colorScheme.onPrimaryContainer,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
Icons.person_outline,
|
|
||||||
size: 20,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
// User info header (non-selectable)
|
|
||||||
PopupMenuItem<String>(
|
|
||||||
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(),
|
itemBuilder: (context) => [
|
||||||
|
// User info header (non-selectable)
|
||||||
// Settings
|
PopupMenuItem<String>(
|
||||||
const PopupMenuItem<String>(
|
enabled: false,
|
||||||
value: 'settings',
|
child: Column(
|
||||||
child: Row(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
|
||||||
Icon(Icons.settings_outlined, size: 20),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Text('Settings'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Theme submenu header
|
|
||||||
PopupMenuItem<String>(
|
|
||||||
enabled: false,
|
|
||||||
height: 32,
|
|
||||||
child: Text(
|
|
||||||
'THEME',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Theme: System
|
|
||||||
PopupMenuItem<String>(
|
|
||||||
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<String>(
|
|
||||||
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<String>(
|
|
||||||
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<String>(
|
|
||||||
value: 'logout',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.logout, size: 20),
|
Text(
|
||||||
SizedBox(width: 12),
|
auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
|
||||||
Text('Logout'),
|
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(),
|
||||||
onSelected: (value) {
|
|
||||||
switch (value) {
|
// Settings
|
||||||
case 'settings':
|
PopupMenuItem<String>(
|
||||||
context.go(AppRoutes.settings);
|
value: 'settings',
|
||||||
case 'theme_system':
|
child: Semantics(
|
||||||
_updateTheme(ref, ThemeSetting.system);
|
identifier: ProfileSemantics.settings,
|
||||||
case 'theme_light':
|
child: const Row(
|
||||||
_updateTheme(ref, ThemeSetting.light);
|
children: [
|
||||||
case 'theme_dark':
|
Icon(Icons.settings_outlined, size: 20),
|
||||||
_updateTheme(ref, ThemeSetting.dark);
|
SizedBox(width: 12),
|
||||||
case 'logout':
|
Text('Settings'),
|
||||||
ref.read(authProvider.notifier).signOut();
|
],
|
||||||
}
|
),
|
||||||
},
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Theme submenu header
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
enabled: false,
|
||||||
|
height: 32,
|
||||||
|
child: Text(
|
||||||
|
'THEME',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Theme: System
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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(
|
loading: () => const CircleAvatar(
|
||||||
radius: 18,
|
radius: 18,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
||||||
|
|
||||||
import 'profile_dropdown.dart';
|
import 'profile_dropdown.dart';
|
||||||
|
|
||||||
@@ -127,17 +128,23 @@ class TopHeaderBar extends StatelessWidget {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Tooltip(
|
child: Semantics(
|
||||||
message: room.label,
|
identifier: RoomTabSemantics.forIndex(index),
|
||||||
child: IconButton(
|
label: room.label,
|
||||||
icon: Icon(
|
button: true,
|
||||||
isSelected ? room.selectedIcon : room.icon,
|
selected: isSelected,
|
||||||
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
|
child: Tooltip(
|
||||||
),
|
message: room.label,
|
||||||
onPressed: () => onRoomSelected(index),
|
child: IconButton(
|
||||||
style: IconButton.styleFrom(
|
icon: Icon(
|
||||||
backgroundColor:
|
isSelected ? room.selectedIcon : room.icon,
|
||||||
isSelected ? colorScheme.primaryContainer : null,
|
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
onPressed: () => onRoomSelected(index),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
isSelected ? colorScheme.primaryContainer : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.1.14+1
|
version: 1.2.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
Reference in New Issue
Block a user