Files
tatlock-ui/lib/shared/layouts/widgets/profile_dropdown.dart
T
Jeroen SchweitzerandClaude Opus 4.5 a6c51f757a
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
feat(semantics): add semantic labels for UI automation
- 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>
2026-01-05 12:21:43 +01:00

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');
}
}
}