diff --git a/APP_SETTINGS.md b/APP_SETTINGS.md new file mode 100644 index 0000000..760b43f --- /dev/null +++ b/APP_SETTINGS.md @@ -0,0 +1,256 @@ +# App Settings & Theme System + +## Overview + +The app uses a centralized `AppSettingsBox` (Hive) for storing all app-level settings. This includes theme preferences, language settings, notification preferences, and other user configurations. + +--- + +## AppSettingsBox + +**Location**: `lib/core/database/app_settings_box.dart` + +### Initialization + +```dart +// In main.dart - call before runApp() +await AppSettingsBox.init(); +``` + +### Storage Keys + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| **Theme** | +| `seed_color_id` | String | `'blue'` | Selected theme color ID | +| `theme_mode` | int | `0` | 0=system, 1=light, 2=dark | +| **Language** | +| `language_code` | String | `'vi'` | Language code (vi, en) | +| **Notifications** | +| `notifications_enabled` | bool | `true` | Master notification toggle | +| `order_notifications` | bool | `true` | Order status notifications | +| `promotion_notifications` | bool | `true` | Promotion notifications | +| `chat_notifications` | bool | `true` | Chat message notifications | +| **User Preferences** | +| `onboarding_completed` | bool | `false` | Onboarding flow completed | +| `biometric_enabled` | bool | `false` | Biometric login enabled | +| `remember_login` | bool | `false` | Remember login credentials | +| **App State** | +| `last_sync_time` | String | - | Last data sync timestamp | +| `app_version` | String | - | Last launched app version | +| `first_launch_date` | String | - | First app launch date | + +### Usage + +```dart +// Generic get/set +AppSettingsBox.get('key', defaultValue: 'default'); +await AppSettingsBox.set('key', value); + +// Helper methods +AppSettingsBox.getSeedColorId(); // Returns 'blue', 'teal', etc. +await AppSettingsBox.setSeedColorId('teal'); + +AppSettingsBox.getThemeModeIndex(); // Returns 0, 1, or 2 +await AppSettingsBox.setThemeModeIndex(1); + +AppSettingsBox.getLanguageCode(); // Returns 'vi' or 'en' +await AppSettingsBox.setLanguageCode('en'); + +AppSettingsBox.areNotificationsEnabled(); // Returns true/false +AppSettingsBox.isOnboardingCompleted(); +AppSettingsBox.isBiometricEnabled(); +``` + +--- + +## Theme System + +### Architecture + +``` +colors.dart → Seed color options & status colors +app_theme.dart → ThemeData generation from seed color +theme_provider.dart → Riverpod state management +``` + +### Available Seed Colors + +| ID | Name | Color | +|----|------|-------| +| `blue` | Xanh dương | `#005B9A` (default) | +| `teal` | Xanh ngọc | `#009688` | +| `green` | Xanh lá | `#4CAF50` | +| `purple` | Tím | `#673AB7` | +| `indigo` | Chàm | `#3F51B5` | +| `orange` | Cam | `#FF5722` | +| `red` | Đỏ | `#E53935` | +| `pink` | Hồng | `#E91E63` | + +### Providers + +```dart +// Main theme settings provider (persisted) +themeSettingsProvider + +// Convenience providers +currentSeedColorProvider // Color - current seed color +seedColorOptionsProvider // List - all options +``` + +### Usage in App + +```dart +// app.dart - Dynamic theme +class MyApp extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(themeSettingsProvider); + + return MaterialApp( + theme: AppTheme.lightTheme(settings.seedColor), + darkTheme: AppTheme.darkTheme(settings.seedColor), + themeMode: settings.themeMode, + // ... + ); + } +} +``` + +### Changing Theme + +```dart +// Change seed color +ref.read(themeSettingsProvider.notifier).setSeedColor('teal'); + +// Change theme mode +ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.dark); + +// Toggle light/dark +ref.read(themeSettingsProvider.notifier).toggleThemeMode(); +``` + +### Color Picker Widget Example + +```dart +class ColorPickerWidget extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final options = ref.watch(seedColorOptionsProvider); + final current = ref.watch(themeSettingsProvider); + + return Wrap( + spacing: 8, + children: options.map((option) { + final isSelected = option.id == current.seedColorId; + return GestureDetector( + onTap: () => ref + .read(themeSettingsProvider.notifier) + .setSeedColor(option.id), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: option.color, + shape: BoxShape.circle, + border: isSelected + ? Border.all(color: Colors.white, width: 3) + : null, + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white) + : null, + ), + ); + }).toList(), + ); + } +} +``` + +--- + +## Using ColorScheme + +With the `fromSeed()` approach, always use `Theme.of(context).colorScheme` for colors: + +```dart +// Via context extension (recommended) +final cs = context.colorScheme; + +// Common color usage +cs.primary // Main brand color (buttons, links) +cs.onPrimary // Text/icons on primary color +cs.primaryContainer // Softer brand background +cs.onPrimaryContainer // Text on primaryContainer + +cs.secondary // Secondary accent +cs.tertiary // Third accent color + +cs.surface // Card/container backgrounds +cs.onSurface // Primary text color +cs.onSurfaceVariant // Secondary text color + +cs.outline // Borders, dividers +cs.outlineVariant // Lighter borders + +cs.error // Error states +cs.onError // Text on error + +// Example widget +Container( + color: cs.primaryContainer, + child: Text( + 'Hello', + style: TextStyle(color: cs.onPrimaryContainer), + ), +) +``` + +### Status Colors (Fixed) + +These colors don't change with theme (from backend): + +```dart +AppColors.success // #28a745 - Green +AppColors.warning // #ffc107 - Yellow +AppColors.danger // #dc3545 - Red +AppColors.info // #17a2b8 - Blue +``` + +--- + +## Files Reference + +| File | Purpose | +|------|---------| +| `lib/core/database/app_settings_box.dart` | Hive storage for all app settings | +| `lib/core/theme/colors.dart` | Seed colors, status colors, gradients | +| `lib/core/theme/app_theme.dart` | ThemeData generation | +| `lib/core/theme/theme_provider.dart` | Riverpod providers for theme | +| `lib/core/theme/typography.dart` | Text styles | + +--- + +## Initialization Order + +```dart +// main.dart +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 1. Initialize Hive + await Hive.initFlutter(); + + // 2. Initialize AppSettingsBox + await AppSettingsBox.init(); + + // 3. Initialize other boxes... + + runApp( + ProviderScope( + child: MyApp(), + ), + ); +} +``` diff --git a/CLAUDE.md b/CLAUDE.md index 3f0d4eb..c866b34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,13 @@ All Dart code examples, patterns, and snippets are maintained in **CODE_EXAMPLES - Localization setup - Deployment configurations +### 🎨 App Settings & Theme: +See **[APP_SETTINGS.md](APP_SETTINGS.md)** for: +- AppSettingsBox (Hive) - centralized app settings storage +- Theme system with dynamic seed colors +- ColorScheme usage guide +- Available seed color options + --- ## 🤖 SUBAGENT DELEGATION SYSTEM 🤖 diff --git a/lib/app.dart b/lib/app.dart index 3798041..b1216e8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/theme/app_theme.dart'; +import 'package:worker/core/theme/theme_provider.dart'; import 'package:worker/generated/l10n/app_localizations.dart'; /// Root application widget for Worker Mobile App @@ -22,6 +23,9 @@ class WorkerApp extends ConsumerWidget { // Watch router provider to get auth-aware router final router = ref.watch(routerProvider); + // Watch theme settings for dynamic theming + final themeSettings = ref.watch(themeSettingsProvider); + return MaterialApp.router( // ==================== App Configuration ==================== debugShowCheckedModeBanner: false, @@ -33,10 +37,10 @@ class WorkerApp extends ConsumerWidget { routerConfig: router, // ==================== Theme Configuration ==================== - // Material 3 theme with brand colors (Primary Blue: #005B9A) - theme: AppTheme.lightTheme(), - darkTheme: AppTheme.darkTheme(), - themeMode: ThemeMode.light, // TODO: Make this configurable from settings + // Material 3 theme with dynamic seed color from settings + theme: AppTheme.lightTheme(themeSettings.seedColor), + darkTheme: AppTheme.darkTheme(themeSettings.seedColor), + themeMode: themeSettings.themeMode, // ==================== Localization Configuration ==================== // Support for Vietnamese (primary) and English (secondary) localizationsDelegates: const [ diff --git a/lib/core/database/app_settings_box.dart b/lib/core/database/app_settings_box.dart new file mode 100644 index 0000000..abe9ced --- /dev/null +++ b/lib/core/database/app_settings_box.dart @@ -0,0 +1,161 @@ +import 'package:hive_ce/hive.dart'; + +/// Central app settings storage using Hive +/// +/// This box stores all app-level settings including: +/// - Theme settings (seed color, theme mode) +/// - Language preferences +/// - Notification settings +/// - User preferences +/// +/// See APP_SETTINGS.md for complete documentation. +class AppSettingsBox { + AppSettingsBox._(); + + static const String boxName = 'app_settings'; + + // ==================== Keys ==================== + + // Theme Settings + static const String seedColorId = 'seed_color_id'; + static const String themeMode = 'theme_mode'; + + // Language Settings + static const String languageCode = 'language_code'; + + // Notification Settings + static const String notificationsEnabled = 'notifications_enabled'; + static const String orderNotifications = 'order_notifications'; + static const String promotionNotifications = 'promotion_notifications'; + static const String chatNotifications = 'chat_notifications'; + + // User Preferences + static const String onboardingCompleted = 'onboarding_completed'; + static const String biometricEnabled = 'biometric_enabled'; + static const String rememberLogin = 'remember_login'; + + // App State + static const String lastSyncTime = 'last_sync_time'; + static const String appVersion = 'app_version'; + static const String firstLaunchDate = 'first_launch_date'; + + // ==================== Box Instance ==================== + + static Box? _box; + + /// Get the app settings box instance + static Box get box { + if (_box == null || !_box!.isOpen) { + throw StateError( + 'AppSettingsBox not initialized. Call AppSettingsBox.init() first.', + ); + } + return _box!; + } + + /// Initialize the app settings box - call before runApp() + static Future init() async { + _box = await Hive.openBox(boxName); + } + + /// Close the box + static Future close() async { + await _box?.close(); + _box = null; + } + + // ==================== Generic Getters/Setters ==================== + + /// Get a value from the box + static T? get(String key, {T? defaultValue}) { + return box.get(key, defaultValue: defaultValue) as T?; + } + + /// Set a value in the box + static Future set(String key, T value) async { + await box.put(key, value); + } + + /// Remove a value from the box + static Future remove(String key) async { + await box.delete(key); + } + + /// Check if a key exists + static bool has(String key) { + return box.containsKey(key); + } + + /// Clear all settings + static Future clear() async { + await box.clear(); + } + + // ==================== Theme Helpers ==================== + + /// Get seed color ID + static String getSeedColorId() { + return get(seedColorId, defaultValue: 'blue') ?? 'blue'; + } + + /// Set seed color ID + static Future setSeedColorId(String colorId) async { + await set(seedColorId, colorId); + } + + /// Get theme mode index (0=system, 1=light, 2=dark) + static int getThemeModeIndex() { + return get(themeMode, defaultValue: 0) ?? 0; + } + + /// Set theme mode index + static Future setThemeModeIndex(int index) async { + await set(themeMode, index); + } + + // ==================== Language Helpers ==================== + + /// Get language code (vi, en) + static String getLanguageCode() { + return get(languageCode, defaultValue: 'vi') ?? 'vi'; + } + + /// Set language code + static Future setLanguageCode(String code) async { + await set(languageCode, code); + } + + // ==================== Notification Helpers ==================== + + /// Check if notifications are enabled + static bool areNotificationsEnabled() { + return get(notificationsEnabled, defaultValue: true) ?? true; + } + + /// Set notifications enabled + static Future setNotificationsEnabled(bool enabled) async { + await set(notificationsEnabled, enabled); + } + + // ==================== User Preference Helpers ==================== + + /// Check if onboarding is completed + static bool isOnboardingCompleted() { + return get(onboardingCompleted, defaultValue: false) ?? false; + } + + /// Set onboarding completed + static Future setOnboardingCompleted(bool completed) async { + await set(onboardingCompleted, completed); + } + + /// Check if biometric is enabled + static bool isBiometricEnabled() { + return get(biometricEnabled, defaultValue: false) ?? false; + } + + /// Set biometric enabled + static Future setBiometricEnabled(bool enabled) async { + await set(biometricEnabled, enabled); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1580777..a2e92fc 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -51,6 +51,7 @@ import 'package:worker/features/showrooms/presentation/pages/design_request_crea import 'package:worker/features/showrooms/presentation/pages/design_request_detail_page.dart'; import 'package:worker/features/showrooms/presentation/pages/model_house_detail_page.dart'; import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart'; +import 'package:worker/features/account/presentation/pages/theme_settings_page.dart'; /// Router Provider /// @@ -484,6 +485,14 @@ final routerProvider = Provider((ref) { MaterialPage(key: state.pageKey, child: const ChangePasswordPage()), ), + // Theme Settings Route + GoRoute( + path: RouteNames.themeSettings, + name: RouteNames.themeSettings, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const ThemeSettingsPage()), + ), + // Chat List Route GoRoute( path: RouteNames.chat, @@ -631,6 +640,7 @@ class RouteNames { static const String addresses = '$account/addresses'; static const String addressForm = '$addresses/form'; static const String changePassword = '$account/change-password'; + static const String themeSettings = '$account/theme-settings'; static const String settings = '$account/settings'; // Promotions & Notifications Routes diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index a2c2f83..b6588e3 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -5,23 +5,18 @@ import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/typography.dart'; /// App theme configuration for Material 3 design system -/// Provides both light and dark theme variants +/// Uses ColorScheme.fromSeed() to auto-generate harmonious colors from brand seed color class AppTheme { - // Prevent instantiation AppTheme._(); // ==================== Light Theme ==================== /// Light theme configuration - static ThemeData lightTheme() { + /// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor + static ThemeData lightTheme([Color? seedColor]) { final ColorScheme colorScheme = ColorScheme.fromSeed( - seedColor: AppColors.primaryBlue, + seedColor: seedColor ?? AppColors.defaultSeedColor, brightness: Brightness.light, - primary: AppColors.primaryBlue, - secondary: AppColors.lightBlue, - tertiary: AppColors.accentCyan, - error: AppColors.danger, - surface: AppColors.white, ); return ThemeData( @@ -29,37 +24,37 @@ class AppTheme { colorScheme: colorScheme, fontFamily: AppTypography.fontFamily, - // ==================== App Bar Theme ==================== + // AppBar uses colorScheme colors appBarTheme: AppBarTheme( elevation: 0, - centerTitle: true, - backgroundColor: AppColors.primaryBlue, - foregroundColor: AppColors.white, + centerTitle: false, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, titleTextStyle: AppTypography.titleLarge.copyWith( - color: AppColors.white, + color: colorScheme.onSurface, fontWeight: FontWeight.w600, ), - iconTheme: const IconThemeData(color: AppColors.white, size: 24), - systemOverlayStyle: SystemUiOverlayStyle.light, + iconTheme: IconThemeData(color: colorScheme.onSurface, size: 24), + systemOverlayStyle: SystemUiOverlayStyle.dark, ), - // ==================== Card Theme ==================== - cardTheme: const CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( + // Card Theme + cardTheme: CardThemeData( + elevation: 1, + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(12)), ), clipBehavior: Clip.antiAlias, - color: AppColors.white, - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: colorScheme.surface, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), - // ==================== Elevated Button Theme ==================== + // Elevated Button Theme elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBlue, - foregroundColor: AppColors.white, - elevation: 2, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + elevation: 1, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, @@ -67,21 +62,21 @@ class AppTheme { ), ), - // ==================== Text Button Theme ==================== + // Text Button Theme textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: AppColors.primaryBlue, + foregroundColor: colorScheme.primary, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, ), ), - // ==================== Outlined Button Theme ==================== + // Outlined Button Theme outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primaryBlue, - side: const BorderSide(color: AppColors.primaryBlue, width: 1.5), + foregroundColor: colorScheme.primary, + side: BorderSide(color: colorScheme.outline, width: 1), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, @@ -89,214 +84,164 @@ class AppTheme { ), ), - // ==================== Input Decoration Theme ==================== + // Input Decoration Theme inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: AppColors.white, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), + fillColor: colorScheme.surfaceContainerLowest, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 1), + borderSide: BorderSide(color: colorScheme.outline, width: 1), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.grey100, width: 1), + borderSide: BorderSide(color: colorScheme.outline, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2), + borderSide: BorderSide(color: colorScheme.primary, width: 2), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.danger, width: 1), + borderSide: BorderSide(color: colorScheme.error, width: 1), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.danger, width: 2), + borderSide: BorderSide(color: colorScheme.error, width: 2), ), - labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), - hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), - errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger), + labelStyle: AppTypography.bodyMedium.copyWith( + color: colorScheme.onSurfaceVariant, + ), + hintStyle: AppTypography.bodyMedium.copyWith( + color: colorScheme.onSurfaceVariant, + ), + errorStyle: AppTypography.bodySmall.copyWith(color: colorScheme.error), ), - // ==================== Bottom Navigation Bar Theme ==================== - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: AppColors.white, - selectedItemColor: AppColors.primaryBlue, - unselectedItemColor: AppColors.grey500, - selectedIconTheme: IconThemeData( - size: 28, - color: AppColors.primaryBlue, + // Bottom Navigation Bar Theme + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: colorScheme.onSurfaceVariant, + selectedIconTheme: IconThemeData(size: 28, color: colorScheme.primary), + unselectedIconTheme: IconThemeData( + size: 24, + color: colorScheme.onSurfaceVariant, ), - unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500), - selectedLabelStyle: TextStyle( + selectedLabelStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, fontFamily: AppTypography.fontFamily, ), - unselectedLabelStyle: TextStyle( + unselectedLabelStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.normal, fontFamily: AppTypography.fontFamily, ), type: BottomNavigationBarType.fixed, - elevation: 8, + elevation: 3, ), - // ==================== Floating Action Button Theme ==================== - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: AppColors.accentCyan, - foregroundColor: AppColors.white, - elevation: 6, - shape: CircleBorder(), - iconSize: 24, + // Floating Action Button Theme + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + elevation: 3, + shape: const CircleBorder(), ), - // ==================== Chip Theme ==================== + // Chip Theme chipTheme: ChipThemeData( - backgroundColor: AppColors.grey50, - selectedColor: AppColors.primaryBlue, - disabledColor: AppColors.grey100, - secondarySelectedColor: AppColors.lightBlue, + backgroundColor: colorScheme.surfaceContainerLow, + selectedColor: colorScheme.primaryContainer, + disabledColor: colorScheme.surfaceContainerLowest, labelStyle: AppTypography.labelMedium, - secondaryLabelStyle: AppTypography.labelMedium.copyWith( - color: AppColors.white, - ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), - // ==================== Dialog Theme ==================== - dialogTheme: - const DialogThemeData( - backgroundColor: AppColors.white, - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), - ), - ).copyWith( - titleTextStyle: AppTypography.headlineMedium.copyWith( - color: AppColors.grey900, - ), - contentTextStyle: AppTypography.bodyLarge.copyWith( - color: AppColors.grey900, - ), - ), + // Dialog Theme + dialogTheme: DialogThemeData( + backgroundColor: colorScheme.surface, + elevation: 3, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + titleTextStyle: AppTypography.headlineMedium.copyWith( + color: colorScheme.onSurface, + ), + contentTextStyle: AppTypography.bodyLarge.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), - // ==================== Snackbar Theme ==================== + // Snackbar Theme snackBarTheme: SnackBarThemeData( - backgroundColor: AppColors.grey900, + backgroundColor: colorScheme.inverseSurface, contentTextStyle: AppTypography.bodyMedium.copyWith( - color: AppColors.white, + color: colorScheme.onInverseSurface, ), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), behavior: SnackBarBehavior.floating, - elevation: 4, + elevation: 3, ), - // ==================== Divider Theme ==================== - dividerTheme: const DividerThemeData( - color: AppColors.grey100, + // Divider Theme + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, thickness: 1, space: 1, ), - // ==================== Icon Theme ==================== - iconTheme: const IconThemeData(color: AppColors.grey900, size: 24), + // Icon Theme + iconTheme: IconThemeData(color: colorScheme.onSurface, size: 24), - // ==================== List Tile Theme ==================== + // List Tile Theme listTileTheme: ListTileThemeData( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), titleTextStyle: AppTypography.titleMedium.copyWith( - color: AppColors.grey900, + color: colorScheme.onSurface, ), subtitleTextStyle: AppTypography.bodyMedium.copyWith( - color: AppColors.grey500, + color: colorScheme.onSurfaceVariant, ), - iconColor: AppColors.grey500, + iconColor: colorScheme.onSurfaceVariant, ), - // ==================== Switch Theme ==================== - switchTheme: SwitchThemeData( - thumbColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.primaryBlue; - } - return AppColors.grey500; - }), - trackColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.lightBlue; - } - return AppColors.grey100; - }), + // Progress Indicator Theme + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + linearTrackColor: colorScheme.surfaceContainerHighest, + circularTrackColor: colorScheme.surfaceContainerHighest, ), - // ==================== Checkbox Theme ==================== - checkboxTheme: CheckboxThemeData( - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.primaryBlue; - } - return AppColors.white; - }), - checkColor: WidgetStateProperty.all(AppColors.white), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), - ), - - // ==================== Radio Theme ==================== - radioTheme: RadioThemeData( - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.primaryBlue; - } - return AppColors.grey500; - }), - ), - - // ==================== Progress Indicator Theme ==================== - progressIndicatorTheme: const ProgressIndicatorThemeData( - color: AppColors.primaryBlue, - linearTrackColor: AppColors.grey100, - circularTrackColor: AppColors.grey100, - ), - - // ==================== Badge Theme ==================== + // Badge Theme badgeTheme: const BadgeThemeData( backgroundColor: AppColors.danger, - textColor: AppColors.white, + textColor: Colors.white, smallSize: 6, largeSize: 16, ), - // ==================== Tab Bar Theme ==================== - tabBarTheme: - const TabBarThemeData( - labelColor: AppColors.primaryBlue, - unselectedLabelColor: AppColors.grey500, - indicatorColor: AppColors.primaryBlue, - ).copyWith( - labelStyle: AppTypography.labelLarge, - unselectedLabelStyle: AppTypography.labelLarge, - ), + // Tab Bar Theme + tabBarTheme: TabBarThemeData( + labelColor: colorScheme.primary, + unselectedLabelColor: colorScheme.onSurfaceVariant, + indicatorColor: colorScheme.primary, + labelStyle: AppTypography.labelLarge, + unselectedLabelStyle: AppTypography.labelLarge, + ), ); } // ==================== Dark Theme ==================== /// Dark theme configuration - static ThemeData darkTheme() { + /// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor + static ThemeData darkTheme([Color? seedColor]) { final ColorScheme colorScheme = ColorScheme.fromSeed( - seedColor: AppColors.primaryBlue, + seedColor: seedColor ?? AppColors.defaultSeedColor, brightness: Brightness.dark, - primary: AppColors.lightBlue, - secondary: AppColors.accentCyan, - tertiary: AppColors.primaryBlue, - error: AppColors.danger, - surface: const Color(0xFF1E1E1E), ); return ThemeData( @@ -304,37 +249,37 @@ class AppTheme { colorScheme: colorScheme, fontFamily: AppTypography.fontFamily, - // ==================== App Bar Theme ==================== + // AppBar Theme appBarTheme: AppBarTheme( elevation: 0, - centerTitle: true, - backgroundColor: const Color(0xFF1E1E1E), - foregroundColor: AppColors.white, + centerTitle: false, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, titleTextStyle: AppTypography.titleLarge.copyWith( - color: AppColors.white, + color: colorScheme.onSurface, fontWeight: FontWeight.w600, ), - iconTheme: const IconThemeData(color: AppColors.white, size: 24), + iconTheme: IconThemeData(color: colorScheme.onSurface, size: 24), systemOverlayStyle: SystemUiOverlayStyle.light, ), - // ==================== Card Theme ==================== - cardTheme: const CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( + // Card Theme + cardTheme: CardThemeData( + elevation: 1, + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(12)), ), clipBehavior: Clip.antiAlias, - color: Color(0xFF1E1E1E), - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: colorScheme.surfaceContainer, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), - // ==================== Elevated Button Theme ==================== + // Elevated Button Theme elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: AppColors.lightBlue, - foregroundColor: AppColors.white, - elevation: 2, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + elevation: 1, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), textStyle: AppTypography.buttonText, @@ -342,78 +287,89 @@ class AppTheme { ), ), - // ==================== Input Decoration Theme ==================== + // Input Decoration Theme inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: const Color(0xFF2A2A2A), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), + fillColor: colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1), + borderSide: BorderSide(color: colorScheme.outline, width: 1), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1), + borderSide: BorderSide(color: colorScheme.outline, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.lightBlue, width: 2), + borderSide: BorderSide(color: colorScheme.primary, width: 2), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.danger, width: 1), + borderSide: BorderSide(color: colorScheme.error, width: 1), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.danger, width: 2), + borderSide: BorderSide(color: colorScheme.error, width: 2), ), - labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), - hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500), - errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger), + labelStyle: AppTypography.bodyMedium.copyWith( + color: colorScheme.onSurfaceVariant, + ), + hintStyle: AppTypography.bodyMedium.copyWith( + color: colorScheme.onSurfaceVariant, + ), + errorStyle: AppTypography.bodySmall.copyWith(color: colorScheme.error), ), - // ==================== Bottom Navigation Bar Theme ==================== - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: Color(0xFF1E1E1E), - selectedItemColor: AppColors.lightBlue, - unselectedItemColor: AppColors.grey500, - selectedIconTheme: IconThemeData(size: 28, color: AppColors.lightBlue), - unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500), - selectedLabelStyle: TextStyle( + // Bottom Navigation Bar Theme + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: colorScheme.onSurfaceVariant, + selectedIconTheme: IconThemeData(size: 28, color: colorScheme.primary), + unselectedIconTheme: IconThemeData( + size: 24, + color: colorScheme.onSurfaceVariant, + ), + selectedLabelStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, fontFamily: AppTypography.fontFamily, ), - unselectedLabelStyle: TextStyle( + unselectedLabelStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.normal, fontFamily: AppTypography.fontFamily, ), type: BottomNavigationBarType.fixed, - elevation: 8, + elevation: 3, ), - // ==================== Floating Action Button Theme ==================== - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: AppColors.accentCyan, - foregroundColor: AppColors.white, - elevation: 6, - shape: CircleBorder(), - iconSize: 24, + // Floating Action Button Theme + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + elevation: 3, + shape: const CircleBorder(), ), - // ==================== Snackbar Theme ==================== + // Snackbar Theme snackBarTheme: SnackBarThemeData( - backgroundColor: const Color(0xFF2A2A2A), + backgroundColor: colorScheme.inverseSurface, contentTextStyle: AppTypography.bodyMedium.copyWith( - color: AppColors.white, + color: colorScheme.onInverseSurface, ), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), behavior: SnackBarBehavior.floating, - elevation: 4, + elevation: 3, + ), + + // Badge Theme + badgeTheme: const BadgeThemeData( + backgroundColor: AppColors.danger, + textColor: Colors.white, + smallSize: 6, + largeSize: 16, ), ); } diff --git a/lib/core/theme/colors.dart b/lib/core/theme/colors.dart index 87d0484..22f2721 100644 --- a/lib/core/theme/colors.dart +++ b/lib/core/theme/colors.dart @@ -1,66 +1,164 @@ import 'package:flutter/material.dart'; +/// Seed color option for theme customization +class SeedColorOption { + const SeedColorOption({ + required this.id, + required this.name, + required this.color, + }); + + final String id; + final String name; + final Color color; +} + /// App color palette following the Worker app design system. /// -/// Primary colors are used for main UI elements, tier colors for membership cards, -/// status colors for feedback, and neutral colors for text and backgrounds. +/// Uses Material 3 ColorScheme.fromSeed() for primary/surface colors. +/// Only status colors and tier gradients are defined here as they come from backend. +/// +/// ## Usage Guide: +/// - For themed colors, use `Theme.of(context).colorScheme.xxx` +/// - For status colors, use `AppColors.success/warning/danger/info` +/// - For tier gradients, use `AppColors.xxxGradient` +/// +/// ## ColorScheme Quick Reference: +/// ```dart +/// final cs = Theme.of(context).colorScheme; +/// cs.primary // Brand color for buttons, links +/// cs.onPrimary // Text/icons on primary +/// cs.primaryContainer // Softer brand backgrounds +/// cs.surface // Card/container backgrounds +/// cs.onSurface // Primary text color +/// cs.onSurfaceVariant // Secondary text color +/// cs.outline // Borders +/// cs.error // Error states +/// ``` class AppColors { - // Primary Colors - /// Main brand color - Used for primary buttons, app bar, etc. - static const primaryBlue = Color(0xFF005B9A); + AppColors._(); - /// Light variant of primary color - Used for highlights and accents - static const lightBlue = Color(0xFF38B6FF); + // ==================== Brand Seed Colors ==================== + /// Default brand color - Used as seed for ColorScheme.fromSeed() + static const Color defaultSeedColor = Color(0xFF005B9A); - /// Accent color for special actions - Used for FAB, links, etc. - static const accentCyan = Color(0xFF35C6F4); + /// Available seed colors for theme customization + /// User can select one of these to change the app's color scheme + static const List seedColorOptions = [ + SeedColorOption( + id: 'blue', + name: 'Xanh dương', + color: Color(0xFF005B9A), + ), + SeedColorOption( + id: 'teal', + name: 'Xanh ngọc', + color: Color(0xFF009688), + ), + SeedColorOption( + id: 'green', + name: 'Xanh lá', + color: Color(0xFF4CAF50), + ), + SeedColorOption( + id: 'purple', + name: 'Tím', + color: Color(0xFF673AB7), + ), + SeedColorOption( + id: 'indigo', + name: 'Chàm', + color: Color(0xFF3F51B5), + ), + SeedColorOption( + id: 'orange', + name: 'Cam', + color: Color(0xFFFF5722), + ), + SeedColorOption( + id: 'red', + name: 'Đỏ', + color: Color(0xFFE53935), + ), + SeedColorOption( + id: 'pink', + name: 'Hồng', + color: Color(0xFFE91E63), + ), + ]; - // Status Colors + /// Get seed color by ID, returns default if not found + static Color getSeedColorById(String? id) { + if (id == null) return defaultSeedColor; + return seedColorOptions + .firstWhere( + (option) => option.id == id, + orElse: () => seedColorOptions.first, + ) + .color; + } + + // ==================== Convenience Aliases (for backward compatibility) ==================== + // DEPRECATED: Prefer using Theme.of(context).colorScheme instead + // These are kept for gradual migration + + /// @Deprecated('Use Theme.of(context).colorScheme.primary instead') + static const Color primaryBlue = defaultSeedColor; + + /// Alias for backward compatibility + static const Color seedColor = defaultSeedColor; + + /// @Deprecated('Use Theme.of(context).colorScheme.primaryContainer') + static const Color lightBlue = Color(0xFF38B6FF); + + /// @Deprecated('Use Theme.of(context).colorScheme.tertiary') + static const Color accentCyan = Color(0xFF35C6F4); + + /// @Deprecated('Use Colors.white or colorScheme.surface instead') + static const Color white = Colors.white; + + /// @Deprecated('Use Theme.of(context).colorScheme.surfaceContainerLowest') + static const Color grey50 = Color(0xFFf8f9fa); + + /// @Deprecated('Use Theme.of(context).colorScheme.outline') + static const Color grey100 = Color(0xFFe9ecef); + + /// @Deprecated('Use Theme.of(context).colorScheme.onSurfaceVariant') + static const Color grey500 = Color(0xFF6c757d); + + /// @Deprecated('Use Theme.of(context).colorScheme.onSurface') + static const Color grey900 = Color(0xFF343a40); + + // ==================== Status Colors (from backend) ==================== /// Success state - Used for completed actions, positive feedback - static const success = Color(0xFF28a745); + static const Color success = Color(0xFF28a745); /// Warning state - Used for caution messages, pending states - static const warning = Color(0xFFffc107); + static const Color warning = Color(0xFFffc107); /// Danger/Error state - Used for errors, destructive actions - static const danger = Color(0xFFdc3545); + static const Color danger = Color(0xFFdc3545); /// Info state - Used for informational messages - static const info = Color(0xFF17a2b8); + static const Color info = Color(0xFF17a2b8); - // Neutral Colors - /// Lightest background shade - static const grey50 = Color(0xFFf8f9fa); - - /// Light background/border shade - static const grey100 = Color(0xFFe9ecef); - - /// Medium grey for secondary text - static const grey500 = Color(0xFF6c757d); - - /// Dark grey for primary text - static const grey900 = Color(0xFF343a40); - - /// Pure white - static const white = Color(0xFFFFFFFF); - - // Tier Gradients for Membership Cards + // ==================== Tier Gradients for Membership Cards ==================== /// Diamond tier gradient (purple-blue) - static const diamondGradient = LinearGradient( + static const LinearGradient diamondGradient = LinearGradient( colors: [Color(0xFF4A00E0), Color(0xFF8E2DE2)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); /// Platinum tier gradient (grey-silver) - static const platinumGradient = LinearGradient( + static const LinearGradient platinumGradient = LinearGradient( colors: [Color(0xFF7F8C8D), Color(0xFFBDC3C7)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); /// Gold tier gradient (yellow-orange) - static const goldGradient = LinearGradient( + static const LinearGradient goldGradient = LinearGradient( colors: [Color(0xFFf7b733), Color(0xFFfc4a1a)], begin: Alignment.topLeft, end: Alignment.bottomRight, diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart new file mode 100644 index 0000000..33ce0f3 --- /dev/null +++ b/lib/core/theme/theme_provider.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:worker/core/database/app_settings_box.dart'; +import 'package:worker/core/theme/colors.dart'; + +part 'theme_provider.g.dart'; + +/// Theme settings state +class ThemeSettings { + const ThemeSettings({ + required this.seedColorId, + required this.themeMode, + }); + + final String seedColorId; + final ThemeMode themeMode; + + /// Get the actual Color from the seed color ID + Color get seedColor => AppColors.getSeedColorById(seedColorId); + + /// Get the SeedColorOption from the ID + SeedColorOption get seedColorOption => AppColors.seedColorOptions.firstWhere( + (option) => option.id == seedColorId, + orElse: () => AppColors.seedColorOptions.first, + ); + + ThemeSettings copyWith({ + String? seedColorId, + ThemeMode? themeMode, + }) { + return ThemeSettings( + seedColorId: seedColorId ?? this.seedColorId, + themeMode: themeMode ?? this.themeMode, + ); + } +} + +/// Provider for managing theme settings with Hive persistence +/// Uses AppSettingsBox for storage +@Riverpod(keepAlive: true) +class ThemeSettingsNotifier extends _$ThemeSettingsNotifier { + @override + ThemeSettings build() { + return _loadFromSettings(); + } + + ThemeSettings _loadFromSettings() { + return ThemeSettings( + seedColorId: AppSettingsBox.getSeedColorId(), + themeMode: ThemeMode.values[AppSettingsBox.getThemeModeIndex()], + ); + } + + /// Update seed color + Future setSeedColor(String colorId) async { + await AppSettingsBox.setSeedColorId(colorId); + state = state.copyWith(seedColorId: colorId); + } + + /// Update theme mode (light/dark/system) + Future setThemeMode(ThemeMode mode) async { + await AppSettingsBox.setThemeModeIndex(mode.index); + state = state.copyWith(themeMode: mode); + } + + /// Toggle between light and dark mode + Future toggleThemeMode() async { + final newMode = + state.themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; + await setThemeMode(newMode); + } +} + +/// Provider for the current seed color (convenience provider) +@riverpod +Color currentSeedColor(Ref ref) { + return ref.watch( + themeSettingsProvider.select((settings) => settings.seedColor), + ); +} + +/// Provider for available seed color options +@riverpod +List seedColorOptions(Ref ref) { + return AppColors.seedColorOptions; +} diff --git a/lib/core/theme/theme_provider.g.dart b/lib/core/theme/theme_provider.g.dart new file mode 100644 index 0000000..afc591e --- /dev/null +++ b/lib/core/theme/theme_provider.g.dart @@ -0,0 +1,171 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider for managing theme settings with Hive persistence +/// Uses AppSettingsBox for storage + +@ProviderFor(ThemeSettingsNotifier) +const themeSettingsProvider = ThemeSettingsNotifierProvider._(); + +/// Provider for managing theme settings with Hive persistence +/// Uses AppSettingsBox for storage +final class ThemeSettingsNotifierProvider + extends $NotifierProvider { + /// Provider for managing theme settings with Hive persistence + /// Uses AppSettingsBox for storage + const ThemeSettingsNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'themeSettingsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$themeSettingsNotifierHash(); + + @$internal + @override + ThemeSettingsNotifier create() => ThemeSettingsNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeSettings value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$themeSettingsNotifierHash() => + r'5befe194684b8c1857302c9573f5eee38199fa97'; + +/// Provider for managing theme settings with Hive persistence +/// Uses AppSettingsBox for storage + +abstract class _$ThemeSettingsNotifier extends $Notifier { + ThemeSettings build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ThemeSettings, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Provider for the current seed color (convenience provider) + +@ProviderFor(currentSeedColor) +const currentSeedColorProvider = CurrentSeedColorProvider._(); + +/// Provider for the current seed color (convenience provider) + +final class CurrentSeedColorProvider + extends $FunctionalProvider + with $Provider { + /// Provider for the current seed color (convenience provider) + const CurrentSeedColorProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'currentSeedColorProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$currentSeedColorHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Color create(Ref ref) { + return currentSeedColor(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Color value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$currentSeedColorHash() => r'c6807df84f2ac257b2650b2f1aa04d2572cbde37'; + +/// Provider for available seed color options + +@ProviderFor(seedColorOptions) +const seedColorOptionsProvider = SeedColorOptionsProvider._(); + +/// Provider for available seed color options + +final class SeedColorOptionsProvider + extends + $FunctionalProvider< + List, + List, + List + > + with $Provider> { + /// Provider for available seed color options + const SeedColorOptionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'seedColorOptionsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$seedColorOptionsHash(); + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + List create(Ref ref) { + return seedColorOptions(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$seedColorOptionsHash() => r'2cb0f7bf9e87394716f44a70b212b4d62f828152'; diff --git a/lib/core/utils/extensions.dart b/lib/core/utils/extensions.dart index 5af77e6..b2aea64 100644 --- a/lib/core/utils/extensions.dart +++ b/lib/core/utils/extensions.dart @@ -7,7 +7,6 @@ library; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; // ============================================================================ diff --git a/lib/features/account/presentation/pages/account_page.dart b/lib/features/account/presentation/pages/account_page.dart index b4cd5d7..0191583 100644 --- a/lib/features/account/presentation/pages/account_page.dart +++ b/lib/features/account/presentation/pages/account_page.dart @@ -166,6 +166,14 @@ class AccountPage extends ConsumerWidget { _showComingSoon(context); }, ), + AccountMenuItem( + icon: FontAwesomeIcons.palette, + title: 'Giao diện', + subtitle: 'Màu sắc và chế độ hiển thị', + onTap: () { + context.push(RouteNames.themeSettings); + }, + ), ], ), ); diff --git a/lib/features/account/presentation/pages/theme_settings_page.dart b/lib/features/account/presentation/pages/theme_settings_page.dart new file mode 100644 index 0000000..89a624e --- /dev/null +++ b/lib/features/account/presentation/pages/theme_settings_page.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/core/theme/theme_provider.dart'; + +/// Theme Settings Page +/// +/// Allows user to customize app theme: +/// - Select seed color from predefined options +/// - Toggle light/dark mode +class ThemeSettingsPage extends ConsumerWidget { + const ThemeSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(themeSettingsProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Giao diện'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + // Color Selection Section + _buildSectionTitle('Màu chủ đề'), + const SizedBox(height: AppSpacing.sm), + _buildColorGrid(context, ref, settings), + const SizedBox(height: AppSpacing.lg), + + // Theme Mode Section + _buildSectionTitle('Chế độ hiển thị'), + const SizedBox(height: AppSpacing.sm), + _buildThemeModeSelector(context, ref, settings, colorScheme), + ], + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ); + } + + Widget _buildColorGrid( + BuildContext context, + WidgetRef ref, + ThemeSettings settings, + ) { + const options = AppColors.seedColorOptions; + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + children: [ + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: AppSpacing.md, + crossAxisSpacing: AppSpacing.md, + childAspectRatio: 1, + ), + itemCount: options.length, + itemBuilder: (context, index) { + final option = options[index]; + final isSelected = option.id == settings.seedColorId; + + return _ColorOption( + option: option, + isSelected: isSelected, + onTap: () { + ref.read(themeSettingsProvider.notifier).setSeedColor(option.id); + }, + ); + }, + ), + const SizedBox(height: AppSpacing.md), + // Current color name + Text( + settings.seedColorOption.name, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildThemeModeSelector( + BuildContext context, + WidgetRef ref, + ThemeSettings settings, + ColorScheme colorScheme, + ) { + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + children: [ + _ThemeModeOption( + icon: FontAwesomeIcons.mobile, + title: 'Theo hệ thống', + subtitle: 'Tự động theo cài đặt thiết bị', + isSelected: settings.themeMode == ThemeMode.system, + onTap: () { + ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.system); + }, + ), + Divider(height: 1, color: colorScheme.outlineVariant), + _ThemeModeOption( + icon: FontAwesomeIcons.sun, + title: 'Sáng', + subtitle: 'Luôn sử dụng giao diện sáng', + isSelected: settings.themeMode == ThemeMode.light, + onTap: () { + ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.light); + }, + ), + Divider(height: 1, color: colorScheme.outlineVariant), + _ThemeModeOption( + icon: FontAwesomeIcons.moon, + title: 'Tối', + subtitle: 'Luôn sử dụng giao diện tối', + isSelected: settings.themeMode == ThemeMode.dark, + onTap: () { + ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.dark); + }, + ), + ], + ), + ); + } +} + +/// Color option widget +class _ColorOption extends StatelessWidget { + const _ColorOption({ + required this.option, + required this.isSelected, + required this.onTap, + }); + + final SeedColorOption option; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: option.color, + shape: BoxShape.circle, + border: isSelected + ? Border.all( + color: Theme.of(context).colorScheme.onSurface, + width: 3, + ) + : null, + boxShadow: isSelected + ? [ + BoxShadow( + color: option.color.withValues(alpha: 0.4), + blurRadius: 8, + spreadRadius: 2, + ), + ] + : null, + ), + child: isSelected + ? const Center( + child: Icon( + Icons.check, + color: Colors.white, + size: 24, + ), + ) + : null, + ), + ); + } +} + +/// Theme mode option widget +class _ThemeModeOption extends StatelessWidget { + const _ThemeModeOption({ + required this.icon, + required this.title, + required this.subtitle, + required this.isSelected, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + FaIcon( + icon, + size: 20, + color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: colorScheme.onSurface, + ), + ), + Text( + subtitle, + style: TextStyle( + fontSize: 13, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + color: colorScheme.primary, + size: 24, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 4390c8a..14a3dea 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -9,7 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/core/router/app_router.dart'; -import 'package:worker/core/theme/colors.dart'; +import 'package:worker/core/utils/extensions.dart'; import 'package:worker/features/cart/presentation/providers/cart_provider.dart'; import 'package:worker/features/home/presentation/providers/member_card_provider.dart'; import 'package:worker/features/home/presentation/providers/promotions_provider.dart'; @@ -59,8 +59,10 @@ class _HomePageState extends ConsumerState { // Watch cart item count final cartItemCount = ref.watch(cartItemCountProvider); + final colorScheme = context.colorScheme; + return Scaffold( - backgroundColor: const Color(0xFFF4F6F8), // --background-gray from CSS + backgroundColor: colorScheme.surfaceContainerLowest, body: CustomScrollView( slivers: [ // Add top padding for status bar @@ -76,7 +78,7 @@ class _HomePageState extends ConsumerState { margin: const EdgeInsets.all(16), height: 200, decoration: BoxDecoration( - color: AppColors.grey100, + color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), ), child: const Center(child: CircularProgressIndicator()), @@ -85,30 +87,30 @@ class _HomePageState extends ConsumerState { margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: AppColors.danger.withValues(alpha: 0.1), + color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(16), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - const FaIcon( + FaIcon( FontAwesomeIcons.circleExclamation, - color: AppColors.danger, + color: colorScheme.error, size: 48, ), const SizedBox(height: 8), Text( l10n.error, - style: const TextStyle( - color: AppColors.danger, + style: TextStyle( + color: colorScheme.error, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( error.toString(), - style: const TextStyle( - color: AppColors.grey500, + style: TextStyle( + color: colorScheme.onSurfaceVariant, fontSize: 12, ), textAlign: TextAlign.center, diff --git a/lib/features/home/presentation/widgets/promotion_slider.dart b/lib/features/home/presentation/widgets/promotion_slider.dart index c62d867..7f7e2ff 100644 --- a/lib/features/home/presentation/widgets/promotion_slider.dart +++ b/lib/features/home/presentation/widgets/promotion_slider.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/core/router/app_router.dart'; -import 'package:worker/core/theme/colors.dart'; import 'package:worker/features/home/domain/entities/promotion.dart'; /// Promotion Slider Widget @@ -35,19 +34,21 @@ class PromotionSlider extends StatelessWidget { return const SizedBox.shrink(); } + final colorScheme = Theme.of(context).colorScheme; + return Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( 'Chương trình ưu đãi', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, - color: Color(0xFF212121), // --text-dark + color: colorScheme.onSurface, ), ), ), @@ -90,13 +91,15 @@ class _PromotionCard extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( onTap: onTap, child: Container( width: 280, margin: const EdgeInsets.only(right: 12), decoration: BoxDecoration( - color: Colors.white, + color: colorScheme.surface, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( @@ -122,16 +125,16 @@ class _PromotionCard extends StatelessWidget { fit: BoxFit.cover, placeholder: (context, url) => Container( height: 140, - color: AppColors.grey100, + color: colorScheme.surfaceContainerHighest, child: const Center(child: CircularProgressIndicator()), ), errorWidget: (context, url, error) => Container( height: 140, - color: AppColors.grey100, - child: const FaIcon( + color: colorScheme.surfaceContainerHighest, + child: FaIcon( FontAwesomeIcons.image, size: 48, - color: AppColors.grey500, + color: colorScheme.onSurfaceVariant, ), ), ), @@ -140,9 +143,9 @@ class _PromotionCard extends StatelessWidget { // Promotion Info Container( padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical( bottom: Radius.circular(12), ), ), @@ -151,10 +154,10 @@ class _PromotionCard extends StatelessWidget { children: [ Text( promotion.title, - style: const TextStyle( + style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: Color(0xFF212121), + color: colorScheme.onSurface, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -162,9 +165,9 @@ class _PromotionCard extends StatelessWidget { const SizedBox(height: 2), Text( promotion.description, - style: const TextStyle( + style: TextStyle( fontSize: 12, - color: Color(0xFF666666), // --text-muted + color: colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/features/home/presentation/widgets/quick_action_item.dart b/lib/features/home/presentation/widgets/quick_action_item.dart index 226526e..b5e43ca 100644 --- a/lib/features/home/presentation/widgets/quick_action_item.dart +++ b/lib/features/home/presentation/widgets/quick_action_item.dart @@ -5,7 +5,6 @@ library; import 'package:flutter/material.dart'; -import 'package:worker/core/theme/colors.dart'; /// Quick Action Item Widget /// @@ -34,15 +33,17 @@ class QuickActionItem extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( - color: Colors.white, + color: colorScheme.surface, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Container( decoration: BoxDecoration( - color: Colors.white, + color: colorScheme.surface, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( @@ -63,15 +64,15 @@ class QuickActionItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ // Icon - Icon(icon, size: 32, color: AppColors.primaryBlue), + Icon(icon, size: 32, color: colorScheme.primary), const SizedBox(height: 8), // Label Text( label, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: Color(0xFF212121), // --text-dark + color: colorScheme.onSurface, ), textAlign: TextAlign.center, maxLines: 1, @@ -90,14 +91,14 @@ class QuickActionItem extends StatelessWidget { vertical: 4, ), decoration: BoxDecoration( - color: AppColors.danger, + color: colorScheme.error, borderRadius: BorderRadius.circular(12), ), constraints: const BoxConstraints(minWidth: 20), child: Text( badge!, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: colorScheme.onError, fontSize: 11, fontWeight: FontWeight.w700, ), diff --git a/lib/features/home/presentation/widgets/quick_action_section.dart b/lib/features/home/presentation/widgets/quick_action_section.dart index aeac1d8..7113fef 100644 --- a/lib/features/home/presentation/widgets/quick_action_section.dart +++ b/lib/features/home/presentation/widgets/quick_action_section.dart @@ -41,10 +41,12 @@ class QuickActionSection extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.white, + color: colorScheme.surface, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( @@ -63,10 +65,10 @@ class QuickActionSection extends StatelessWidget { // Section Title Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, - color: Color(0xFF212121), // --text-dark + color: colorScheme.onSurface, height: 1.0, // Reduce line height to minimize spacing ), ), diff --git a/lib/features/main/presentation/pages/main_scaffold.dart b/lib/features/main/presentation/pages/main_scaffold.dart index 80eeb94..a5e8193 100644 --- a/lib/features/main/presentation/pages/main_scaffold.dart +++ b/lib/features/main/presentation/pages/main_scaffold.dart @@ -8,14 +8,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/core/router/app_router.dart'; -import 'package:worker/core/theme/colors.dart'; import 'package:worker/features/account/presentation/pages/account_page.dart'; import 'package:worker/features/home/presentation/pages/home_page.dart'; import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart'; import 'package:worker/features/main/presentation/providers/current_page_provider.dart'; import 'package:worker/features/news/presentation/pages/news_list_page.dart'; import 'package:worker/features/notifications/presentation/pages/notifications_page.dart'; -import 'package:worker/features/promotions/presentation/pages/promotions_page.dart'; /// Main Scaffold Page /// @@ -31,6 +29,7 @@ class MainScaffold extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentIndex = ref.watch(currentPageIndexProvider); + final colorScheme = Theme.of(context).colorScheme; // Define pages final pages = [ @@ -48,11 +47,11 @@ class MainScaffold extends ConsumerWidget { padding: const EdgeInsets.only(bottom: 20), child: FloatingActionButton( onPressed: () => context.push(RouteNames.chat), - backgroundColor: const Color(0xFF35C6F4), // Accent cyan color + backgroundColor: colorScheme.tertiary, elevation: 4, - child: const Icon( + child: Icon( Icons.chat_bubble, - color: AppColors.white, + color: colorScheme.onTertiary, size: 28, ), ), @@ -60,7 +59,7 @@ class MainScaffold extends ConsumerWidget { : null, bottomNavigationBar: Container( decoration: BoxDecoration( - color: Colors.white, + color: colorScheme.surface, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), @@ -74,9 +73,9 @@ class MainScaffold extends ConsumerWidget { height: 70, child: BottomNavigationBar( type: BottomNavigationBarType.fixed, - backgroundColor: Colors.white, - selectedItemColor: AppColors.primaryBlue, - unselectedItemColor: const Color(0xFF666666), + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: colorScheme.onSurfaceVariant, selectedFontSize: 11, unselectedFontSize: 11, iconSize: 24, @@ -109,17 +108,17 @@ class MainScaffold extends ConsumerWidget { vertical: 2, ), decoration: BoxDecoration( - color: AppColors.danger, + color: colorScheme.error, borderRadius: BorderRadius.circular(12), ), constraints: const BoxConstraints( minWidth: 20, minHeight: 20, ), - child: const Text( + child: Text( '5', style: TextStyle( - color: Colors.white, + color: colorScheme.onError, fontSize: 11, fontWeight: FontWeight.w700, ), diff --git a/lib/main.dart b/lib/main.dart index 8cacf52..e83ee4c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:onesignal_flutter/onesignal_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:worker/app.dart'; +import 'package:worker/core/database/app_settings_box.dart'; import 'package:worker/core/database/hive_initializer.dart'; /// Main entry point of the Worker Mobile App @@ -122,6 +123,9 @@ Future _initializeHive() async { verbose: kDebugMode, // Verbose logging in debug mode ); + // Initialize AppSettingsBox for app settings (theme, language, etc.) + await AppSettingsBox.init(); + debugPrint('Hive database initialized successfully'); } catch (error, stackTrace) { debugPrint('Failed to initialize Hive: $error');