diff --git a/CHANGELOG.md b/CHANGELOG.md index e884fc8..be944b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.13] - 2026-01-04 + +### Added +- Settings page with Appearance, Navigation, and Account sections +- Theme toggle in user profile dropdown (System/Light/Dark) +- Theme syncs with API preferences on login +- Default room preference syncs with backend + +### Changed +- Theme changes now persist to both local storage and API + ## [1.1.12] - 2026-01-04 ### Changed diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart index 6cbca9d..2e7707c 100644 --- a/lib/core/theme/theme_provider.dart +++ b/lib/core/theme/theme_provider.dart @@ -1,6 +1,9 @@ +import 'dart:developer' as developer; + import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tatlock_ui/core/auth/auth_provider.dart'; part 'theme_provider.g.dart'; @@ -17,13 +20,21 @@ enum ThemeSetting { } /// Provider for theme setting state. +/// +/// Syncs with API preferences when user is authenticated. On login, the theme +/// from API preferences takes precedence over local storage. @riverpod class ThemeNotifier extends _$ThemeNotifier { static const _prefsKey = 'theme_setting'; @override ThemeSetting build() { + // Load local setting first for immediate UI _loadSavedSetting(); + + // Listen for auth state changes to sync from API preferences + _syncFromAuthPreferences(); + return ThemeSetting.system; } @@ -39,6 +50,36 @@ class ThemeNotifier extends _$ThemeNotifier { } } + /// Listen to auth state and sync theme from API preferences. + void _syncFromAuthPreferences() { + ref.listen(authProvider, (_, next) { + next.whenData((auth) { + final apiTheme = auth.preferences?.theme; + if (apiTheme != null && apiTheme.isNotEmpty) { + try { + final themeSetting = ThemeSetting.values.byName(apiTheme); + if (themeSetting != state) { + developer.log( + 'Syncing theme from API: $apiTheme', + name: 'theme', + ); + state = themeSetting; + // Also persist to local storage for offline use + _saveToLocalStorage(themeSetting); + } + } catch (_) { + // Invalid theme value from API, keep current + } + } + }); + }); + } + + Future _saveToLocalStorage(ThemeSetting setting) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, setting.name); + } + /// Update theme setting and persist to storage. Future setSetting(ThemeSetting setting) async { state = setting; diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart new file mode 100644 index 0000000..45de5db --- /dev/null +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -0,0 +1,306 @@ +import 'dart:developer' as developer; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tatlock_ui/core/auth/auth_provider.dart'; +import 'package:tatlock_ui/core/theme/theme_provider.dart'; + +/// Settings page with user preferences. +class SettingsPage extends ConsumerWidget { + const SettingsPage({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; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: authState.when( + data: (auth) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Page header + Text( + 'Settings', + style: textTheme.headlineMedium, + ), + const SizedBox(height: 32), + + // Appearance section + _SectionHeader(title: 'Appearance'), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + _themeIcon(currentTheme), + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Theme', style: textTheme.titleMedium), + Text( + 'Choose your preferred color scheme', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + DropdownButton( + value: currentTheme, + underline: const SizedBox(), + onChanged: (value) { + if (value != null) { + _updateTheme(ref, value); + } + }, + items: const [ + DropdownMenuItem( + value: ThemeSetting.system, + child: Text('System'), + ), + DropdownMenuItem( + value: ThemeSetting.light, + child: Text('Light'), + ), + DropdownMenuItem( + value: ThemeSetting.dark, + child: Text('Dark'), + ), + ], + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // Navigation section + _SectionHeader(title: 'Navigation'), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + Icons.home_outlined, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Default Room', + style: textTheme.titleMedium, + ), + Text( + 'Room to show when app opens', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + DropdownButton( + value: + auth.preferences?.defaultRoom ?? 'front-hall', + underline: const SizedBox(), + onChanged: (value) { + if (value != null) { + _updateDefaultRoom(ref, value); + } + }, + items: const [ + DropdownMenuItem( + value: 'front-hall', + child: Text('Front Hall'), + ), + DropdownMenuItem( + value: 'control-room', + child: Text('Control Room'), + ), + DropdownMenuItem( + value: 'parlor', + child: Text('Parlor'), + ), + ], + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // Account section + _SectionHeader(title: 'Account'), + const SizedBox(height: 8), + Card( + child: Column( + children: [ + _AccountInfoTile( + icon: Icons.person_outline, + label: 'Name', + value: auth.userName ?? 'Not available', + ), + const Divider(height: 1), + _AccountInfoTile( + icon: Icons.email_outlined, + label: 'Email', + value: auth.userEmail ?? 'Not available', + ), + if (auth.roles.isNotEmpty) ...[ + const Divider(height: 1), + _AccountInfoTile( + icon: Icons.shield_outlined, + label: 'Roles', + value: auth.roles.map((r) => r.name).join(', '), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 16), + Text( + 'Failed to load settings', + style: textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } + + IconData _themeIcon(ThemeSetting theme) { + return switch (theme) { + ThemeSetting.system => Icons.brightness_auto, + ThemeSetting.light => Icons.light_mode, + ThemeSetting.dark => Icons.dark_mode, + }; + } + + Future _updateTheme(WidgetRef ref, ThemeSetting setting) async { + // Update local theme immediately for instant UI response + await ref.read(themeProvider.notifier).setSetting(setting); + + // Sync to backend + try { + await ref.read(authProvider.notifier).updatePreferences( + theme: setting.name, + ); + } catch (e) { + developer.log('Failed to sync theme preference: $e', name: 'settings'); + } + } + + Future _updateDefaultRoom(WidgetRef ref, String room) async { + try { + await ref.read(authProvider.notifier).updatePreferences( + defaultRoom: room, + ); + } catch (e) { + developer.log('Failed to sync default room: $e', name: 'settings'); + } + } +} + +/// Section header widget. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Text( + title.toUpperCase(), + style: textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + letterSpacing: 1.0, + fontWeight: FontWeight.w600, + ), + ); + } +} + +/// Account info tile widget. +class _AccountInfoTile extends StatelessWidget { + const _AccountInfoTile({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + )), + const SizedBox(height: 2), + Text(value, style: textTheme.titleMedium), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index dd028d4..54cfc03 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -4,6 +4,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart'; import 'package:tatlock_ui/features/security/router.dart'; +import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart'; import 'package:tatlock_ui/shared/layouts/app_scaffold.dart'; part 'app_router.g.dart'; @@ -52,7 +53,7 @@ GoRouter appRouter(Ref ref) { path: AppRoutes.settings, name: 'settings', pageBuilder: (context, state) => - noTransitionPage(context, state, const _PlaceholderPage(title: 'Settings')), + noTransitionPage(context, state, const SettingsPage()), ), ], ), diff --git a/lib/shared/layouts/widgets/profile_dropdown.dart b/lib/shared/layouts/widgets/profile_dropdown.dart index dd734c2..5e8950b 100644 --- a/lib/shared/layouts/widgets/profile_dropdown.dart +++ b/lib/shared/layouts/widgets/profile_dropdown.dart @@ -1,7 +1,10 @@ +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/theme/theme_provider.dart'; import 'package:tatlock_ui/routing/app_router.dart'; /// Profile dropdown menu in the header. @@ -13,6 +16,7 @@ class ProfileDropdown extends ConsumerWidget { @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( @@ -76,6 +80,102 @@ class ProfileDropdown extends ConsumerWidget { ), ), + // 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( @@ -93,6 +193,12 @@ class ProfileDropdown extends ConsumerWidget { 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(); } @@ -126,4 +232,20 @@ class ProfileDropdown extends ConsumerWidget { } return name[0].toUpperCase(); } + + /// Update theme locally and sync to API. + Future _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'); + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 809385b..7bd28a5 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.12+1 +version: 1.1.13+1 environment: sdk: ^3.10.4