- Created lib/core/semantics/ with semantic ID constants and helper widget
- Enabled SemanticsBinding on web builds for accessibility tree exposure
- Added semantic IDs to:
- ProfileDropdown (button, settings, theme options, logout)
- TopHeaderBar room tabs (frontHall, controlRoom, security, parlor)
- NavPanel items (nav_item_{id})
This enables browser automation tools like Puppeteer and WebDriver to
discover and interact with Flutter widgets via the accessibility tree.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
273 lines
9.1 KiB
Dart
273 lines
9.1 KiB
Dart
import 'dart:developer' as developer;
|
|
|
|
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';
|
|
|
|
/// Profile dropdown menu in the header.
|
|
///
|
|
/// Shows user info when authenticated, with Settings and Logout options.
|
|
class ProfileDropdown extends ConsumerWidget {
|
|
const ProfileDropdown({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final authState = ref.watch(authProvider);
|
|
final currentTheme = ref.watch(themeProvider);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return authState.when(
|
|
data: (auth) => Semantics(
|
|
identifier: ProfileSemantics.button,
|
|
label: 'Profile menu',
|
|
button: true,
|
|
child: PopupMenuButton<String>(
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
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(),
|
|
|
|
// Settings
|
|
PopupMenuItem<String>(
|
|
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<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(
|
|
radius: 18,
|
|
child: SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
error: (err, stack) => CircleAvatar(
|
|
radius: 18,
|
|
backgroundColor: colorScheme.errorContainer,
|
|
child: Icon(
|
|
Icons.error_outline,
|
|
size: 20,
|
|
color: colorScheme.onErrorContainer,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _getInitials(String? name) {
|
|
if (name == null || name.isEmpty) return '?';
|
|
final parts = name.trim().split(' ');
|
|
if (parts.length >= 2) {
|
|
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
|
}
|
|
return name[0].toUpperCase();
|
|
}
|
|
|
|
/// Update theme locally and sync to API.
|
|
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
|
|
// Update local theme immediately for instant UI response
|
|
await ref.read(themeProvider.notifier).setSetting(setting);
|
|
|
|
// Sync to backend (fire-and-forget, errors logged not shown)
|
|
try {
|
|
await ref.read(authProvider.notifier).updatePreferences(
|
|
theme: setting.name,
|
|
);
|
|
} catch (e) {
|
|
// Theme still works locally even if API sync fails
|
|
developer.log('Failed to sync theme preference: $e', name: 'profile');
|
|
}
|
|
}
|
|
}
|