diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 09:25:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 09:25:48 +0300 |
| commit | cce598d8b698ecf4490127d5fad9bd0995ec1050 (patch) | |
| tree | 11e617532e10915097c962f877b61e4476a33751 /player-android | |
| parent | 1824175276fcd29d4b6d4c14277c62e73247b7e2 (diff) | |
Implement Material 3 theming with light + dark mode toggle (fb)
Adds ThemeNotifier (AsyncNotifier) backed by shared_preferences to persist
the user's light/dark/system preference across restarts. Color schemes derive
from the dark and light palettes defined in player-server/docs/theming.md.
MaterialApp now consumes themeProvider for themeMode, theme, and darkTheme;
SettingsScreen gains a SegmentedButton appearance section for the toggle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/lib/main.dart | 18 | ||||
| -rw-r--r-- | player-android/lib/providers/theme_provider.dart | 169 | ||||
| -rw-r--r-- | player-android/lib/screens/settings_screen.dart | 74 |
3 files changed, 259 insertions, 2 deletions
diff --git a/player-android/lib/main.dart b/player-android/lib/main.dart index 99e85cb..2c1b193 100644 --- a/player-android/lib/main.dart +++ b/player-android/lib/main.dart @@ -5,6 +5,7 @@ import 'package:just_audio/just_audio.dart'; import 'providers/audio_handler_provider.dart'; import 'providers/progress_queue_provider.dart'; +import 'providers/theme_provider.dart'; import 'router.dart'; import 'services/audio_handler.dart'; @@ -68,9 +69,13 @@ void main() async { /// Root application widget. /// -/// Uses [ConsumerWidget] to read [routerProvider] from Riverpod so that the -/// same [GoRouter] instance (and its navigator key) is reused across rebuilds. +/// Uses [ConsumerWidget] to read [routerProvider] and [themeProvider] from +/// Riverpod so that the same [GoRouter] instance and the persisted [ThemeMode] +/// are both available without additional state management in the widget itself. +/// /// [MaterialApp.router] delegates all navigation decisions to go_router. +/// [themeMode] is driven by [themeProvider] so the user's light/dark preference +/// takes effect immediately on every screen and survives app restarts. class PlayerAndroidApp extends ConsumerWidget { const PlayerAndroidApp({super.key}); @@ -78,9 +83,18 @@ class PlayerAndroidApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); + // Default to ThemeMode.system while the preference is loading so the app + // does not flash an incorrect theme during startup. + final themeMode = + ref.watch(themeProvider).valueOrNull ?? ThemeMode.system; + return MaterialApp.router( title: 'Player', routerConfig: router, + // Material 3 is enabled in both ThemeData instances; see theme_provider.dart. + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + themeMode: themeMode, ); } } diff --git a/player-android/lib/providers/theme_provider.dart b/player-android/lib/providers/theme_provider.dart new file mode 100644 index 0000000..7ebaa89 --- /dev/null +++ b/player-android/lib/providers/theme_provider.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// SharedPreferences key for the persisted theme mode. +const _kThemeModeKey = 'theme_mode'; + +// Mapping between the persisted string value and [ThemeMode] enum. +// Using explicit strings (not enum index) so the stored values are stable +// across code refactors that might change enum ordering. +const _kLight = 'light'; +const _kDark = 'dark'; +const _kSystem = 'system'; + +/// Returns the [ThemeMode] that corresponds to a persisted string. +/// +/// Falls back to [ThemeMode.system] for any unknown or null value so that a +/// fresh install (or a corrupt preference) always behaves sensibly. +ThemeMode _themeModeFromString(String? value) => switch (value) { + _kLight => ThemeMode.light, + _kDark => ThemeMode.dark, + _ => ThemeMode.system, + }; + +/// Returns the string that is persisted for a given [ThemeMode]. +String _themeModeToString(ThemeMode mode) => switch (mode) { + ThemeMode.light => _kLight, + ThemeMode.dark => _kDark, + ThemeMode.system => _kSystem, + }; + +// --------------------------------------------------------------------------- +// Color schemes derived from player-server/docs/theming.md +// +// Dark palette mirrors the CSS :root block; light palette mirrors +// [data-theme="light"]. Material 3 ColorScheme is built from the key tokens: +// primary ← --accent +// onPrimary ← --text-inverse / white +// surface ← --bg-surface +// background ← --bg-body +// error ← --danger +// --------------------------------------------------------------------------- + +/// Material 3 dark [ColorScheme] matching the server's default dark palette. +const darkColorScheme = ColorScheme( + brightness: Brightness.dark, + // Accent #5e9eff — the interactive highlight colour. + primary: Color(0xFF5E9EFF), + onPrimary: Color(0xFF0B0D12), // --text-inverse: dark text on accent buttons. + primaryContainer: Color(0xFF1E222C), // --bg-elevated + onPrimaryContainer: Color(0xFFE6E8EF), // --text-primary + secondary: Color(0xFF3DDC84), // --success: used for "playing" states. + onSecondary: Color(0xFF0B0D12), + secondaryContainer: Color(0xFF161920), // --bg-surface + onSecondaryContainer: Color(0xFFA3A8B8), // --text-secondary + tertiary: Color(0xFFFFB300), // --warn + onTertiary: Color(0xFF0B0D12), + tertiaryContainer: Color(0xFF1B1F27), // --bg-surface-hover + onTertiaryContainer: Color(0xFFE6E8EF), + error: Color(0xFFF25C5C), // --danger + onError: Color(0xFFFFFFFF), + errorContainer: Color(0xFF1E222C), + onErrorContainer: Color(0xFFF25C5C), + surface: Color(0xFF161920), // --bg-surface + onSurface: Color(0xFFE6E8EF), // --text-primary + onSurfaceVariant: Color(0xFFA3A8B8), // --text-secondary + outline: Color(0xFF252A36), // --border + outlineVariant: Color(0xFF2E3546), // --border-strong + shadow: Color(0xFF000000), + scrim: Color(0xFF000000), + inverseSurface: Color(0xFFE6E8EF), + onInverseSurface: Color(0xFF0F1117), + inversePrimary: Color(0xFF2B6CB0), // light accent for chip labels on dark bg +); + +/// Material 3 light [ColorScheme] matching the server's [data-theme="light"] palette. +const lightColorScheme = ColorScheme( + brightness: Brightness.light, + // Accent #2b6cb0 — the interactive highlight colour in light mode. + primary: Color(0xFF2B6CB0), + onPrimary: Color(0xFFFFFFFF), // --text-inverse: white text on accent buttons. + primaryContainer: Color(0xFFFFFFFF), // --bg-elevated + onPrimaryContainer: Color(0xFF12131A), // --text-primary + secondary: Color(0xFF258855), // --success + onSecondary: Color(0xFFFFFFFF), + secondaryContainer: Color(0xFFFFFFFF), // --bg-surface + onSecondaryContainer: Color(0xFF4A4F5E), // --text-secondary + tertiary: Color(0xFFFFB300), // --warn (unchanged in light mode) + onTertiary: Color(0xFF12131A), + tertiaryContainer: Color(0xFFF0F2F7), // --bg-surface-hover + onTertiaryContainer: Color(0xFF12131A), + error: Color(0xFFC53030), // --danger (light variant) + onError: Color(0xFFFFFFFF), + errorContainer: Color(0xFFF4F5F8), + onErrorContainer: Color(0xFFC53030), + surface: Color(0xFFFFFFFF), // --bg-surface + onSurface: Color(0xFF12131A), // --text-primary + onSurfaceVariant: Color(0xFF4A4F5E), // --text-secondary + outline: Color(0xFFD6DAE4), // --border + outlineVariant: Color(0xFFC3C9D6), // --border-strong + shadow: Color(0xFF000000), + scrim: Color(0xFF000000), + inverseSurface: Color(0xFF12131A), + onInverseSurface: Color(0xFFF4F5F8), + inversePrimary: Color(0xFF5E9EFF), // dark accent for chip labels on light bg +); + +// --------------------------------------------------------------------------- +// ThemeData factories +// --------------------------------------------------------------------------- + +/// Builds a Material 3 [ThemeData] for dark mode. +/// +/// [useMaterial3] must be true so that the ColorScheme tokens above are +/// interpreted correctly by all M3 components (NavigationBar, Card, etc.). +ThemeData buildDarkTheme() => ThemeData( + useMaterial3: true, + colorScheme: darkColorScheme, + scaffoldBackgroundColor: const Color(0xFF0F1117), // --bg-body dark + ); + +/// Builds a Material 3 [ThemeData] for light mode. +ThemeData buildLightTheme() => ThemeData( + useMaterial3: true, + colorScheme: lightColorScheme, + scaffoldBackgroundColor: const Color(0xFFF4F5F8), // --bg-body light + ); + +// --------------------------------------------------------------------------- +// ThemeNotifier +// --------------------------------------------------------------------------- + +/// Manages the user's preferred [ThemeMode] and persists it via [SharedPreferences]. +/// +/// Uses [AsyncNotifier] because the initial load requires an async disk read. +/// After the first load, [setThemeMode] updates the in-memory state immediately +/// and then persists to disk so the UI is never blocked on I/O. +/// +/// Design notes (SRP): +/// - Theme persistence is isolated here; color definitions live as constants +/// above. [SettingsNotifier] handles other persisted settings (server URL) +/// and is kept separate to avoid growing a god-class. +class ThemeNotifier extends AsyncNotifier<ThemeMode> { + @override + Future<ThemeMode> build() async { + // Read the persisted theme preference on first access. SharedPreferences + // returns a cached singleton on subsequent calls so this is cheap. + final prefs = await SharedPreferences.getInstance(); + return _themeModeFromString(prefs.getString(_kThemeModeKey)); + } + + /// Updates the active [ThemeMode] and persists the choice to disk. + /// + /// The in-memory state is updated first so that [MaterialApp.themeMode] + /// changes immediately; the disk write follows asynchronously. + Future<void> setThemeMode(ThemeMode mode) async { + state = AsyncData(mode); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kThemeModeKey, _themeModeToString(mode)); + } +} + +/// The single source of truth for the active [ThemeMode]. +/// +/// Consumed by [PlayerAndroidApp] (via [themeProvider]) to set +/// [MaterialApp.themeMode], and by [SettingsScreen] to render the toggle. +final themeProvider = AsyncNotifierProvider<ThemeNotifier, ThemeMode>( + ThemeNotifier.new, +); diff --git a/player-android/lib/screens/settings_screen.dart b/player-android/lib/screens/settings_screen.dart index be812eb..25bb6bc 100644 --- a/player-android/lib/screens/settings_screen.dart +++ b/player-android/lib/screens/settings_screen.dart @@ -6,6 +6,7 @@ import '../app_routes.dart'; import '../providers/api_client_provider.dart'; import '../providers/auth_state_provider.dart'; import '../providers/settings_provider.dart'; +import '../providers/theme_provider.dart'; /// Settings screen: editable server base URL, current username, and logout. /// @@ -213,6 +214,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { const SizedBox(height: 24), // ---------------------------------------------------------------- + // Appearance section: light / dark / system theme toggle. + // ---------------------------------------------------------------- + Text( + 'Appearance', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + _ThemeToggle(), + + const SizedBox(height: 32), + const Divider(), + const SizedBox(height: 24), + + // ---------------------------------------------------------------- // Sharing section: navigate to MyShares screen. // ---------------------------------------------------------------- Text( @@ -240,6 +256,64 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { } // --------------------------------------------------------------------------- +// Theme toggle widget +// --------------------------------------------------------------------------- + +/// Segmented-button control that lets the user choose between +/// light, dark, and system (follow OS) theme modes. +/// +/// Kept as a separate [ConsumerWidget] (SRP) so [_SettingsScreenState] does +/// not need to know about [themeProvider] — it only needs to place the widget. +class _ThemeToggle extends ConsumerWidget { + // ignore: prefer_const_constructors_in_immutables — private widget, not const + _ThemeToggle(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Default to system while the provider is loading so the toggle renders + // immediately rather than showing an empty state. + final current = ref.watch(themeProvider).valueOrNull ?? ThemeMode.system; + + return _buildSegmentedButton(context, ref, current); + } + + Widget _buildSegmentedButton( + BuildContext context, + WidgetRef ref, + ThemeMode current, + ) { + return SegmentedButton<ThemeMode>( + key: const Key('settings_theme_toggle'), + segments: const [ + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode_outlined), + label: Text('Light'), + ), + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.brightness_auto_outlined), + label: Text('System'), + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode_outlined), + label: Text('Dark'), + ), + ], + selected: {current}, + // Allow only single selection — the user always has exactly one mode active. + multiSelectionEnabled: false, + onSelectionChanged: (selection) { + if (selection.isNotEmpty) { + ref.read(themeProvider.notifier).setThemeMode(selection.first); + } + }, + ); + } +} + +// --------------------------------------------------------------------------- // File-level helpers // --------------------------------------------------------------------------- |
