diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index f2fa223..6ae80e5 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:frontend/theme/catppuccin_mocha_theme.dart'; import 'package:frontend/utils/pref_utils.dart'; import 'package:provider/provider.dart'; import 'navigation.dart'; +import 'theme/gruvbox_theme.dart'; void main() async { - PrefUtil.init(); + WidgetsFlutterBinding.ensureInitialized(); + await PrefUtil.init(); runApp(const MainApp()); } @@ -15,20 +18,36 @@ final class MainApp extends StatelessWidget { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) => AppState(), - child: MaterialApp.router( - title: 'OOTT', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Color.fromARGB(255, 214, 93, 14), - brightness: Brightness.dark, - ), + child: Consumer( + builder: (context, appState, _) => MaterialApp.router( + title: 'OOTT', + theme: appState.theme, + routerConfig: router, ), - routerConfig: router, ), ); } } +final _themes = { + 'catppuccin_mocha': catppuccinMochaDarkTheme, + 'gruvbox_dark': gruvboxDarkTheme, +}; + class AppState extends ChangeNotifier { - // Global app state goes here + AppState() { + final saved = PrefUtil.getValue('theme', 'catppuccin_mocha') as String; + _themeKey = _themes.containsKey(saved) ? saved : 'catppuccin_mocha'; + } + + late String _themeKey; + String get themeKey => _themeKey; + ThemeData get theme => _themes[_themeKey]!; + + void setTheme(String key) { + if (!_themes.containsKey(key) || key == _themeKey) return; + _themeKey = key; + PrefUtil.setValue('theme', key); + notifyListeners(); + } } diff --git a/frontend/lib/settings/settings.dart b/frontend/lib/settings/settings.dart index 4599cb4..e0bf73f 100644 --- a/frontend/lib/settings/settings.dart +++ b/frontend/lib/settings/settings.dart @@ -1,6 +1,9 @@ import 'package:encrypter/encrypter/xor.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../main.dart'; +import '../theme/app_colors.dart'; import '../utils/oott_api.dart'; import '../utils/pref_utils.dart'; import '../utils/ui_snackbars.dart'; @@ -19,6 +22,8 @@ class _SettingsState extends State { final _apiKeyController = TextEditingController(); bool _apiKeyVisible = false; bool _testOk = false; + bool _connectionModified = false; + late String _selectedTheme; final _formKey = GlobalKey(); @@ -40,6 +45,7 @@ class _SettingsState extends State { _apiKeyController.text = XOR().xorDecode( PrefUtil.getValue("api_key", "") as String, ); + _selectedTheme = context.read().themeKey; _isLoading = false; setState(() {}); } @@ -66,6 +72,7 @@ class _SettingsState extends State { onChanged: (text) { setState(() { _testOk = false; + _connectionModified = true; }); }, validator: (value) { @@ -87,6 +94,7 @@ class _SettingsState extends State { onChanged: (text) { setState(() { _testOk = false; + _connectionModified = true; }); }, @@ -115,6 +123,32 @@ class _SettingsState extends State { ), ), SizedBox(height: 16), + // Theme selector + DropdownButtonFormField( + value: _selectedTheme, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: 'Theme', + ), + items: const [ + DropdownMenuItem( + value: 'catppuccin_mocha', + child: Text('Catppuccin Mocha'), + ), + DropdownMenuItem( + value: 'gruvbox_dark', + child: Text('Gruvbox Dark'), + ), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _selectedTheme = value; + }); + } + }, + ), + SizedBox(height: 16), // Button row Row( mainAxisAlignment: MainAxisAlignment.end, @@ -130,6 +164,8 @@ class _SettingsState extends State { setState(() { _testOk = testResult == null; + if (testResult == null) + _connectionModified = false; }); if (testResult == null) { @@ -145,10 +181,14 @@ class _SettingsState extends State { : Icon(Icons.play_arrow), style: ElevatedButton.styleFrom( backgroundColor: _testOk - ? Colors.lightGreen + ? Theme.of( + context, + ).extension()!.success : Theme.of(context).colorScheme.secondary, foregroundColor: _testOk - ? Theme.of(context).colorScheme.onPrimary + ? Theme.of( + context, + ).extension()!.onSuccess : Theme.of(context).colorScheme.onSecondary, ), ), @@ -156,7 +196,8 @@ class _SettingsState extends State { // Save button ElevatedButton.icon( - onPressed: ((!_testOk) || _isSaving) + onPressed: + ((_connectionModified && !_testOk) || _isSaving) ? null : () async { if (_formKey.currentState!.validate()) { @@ -164,6 +205,9 @@ class _SettingsState extends State { _isSaving = true; }); _saveData(); + context.read().setTheme( + _selectedTheme, + ); setState(() { _isSaving = false; diff --git a/frontend/lib/theme/app_colors.dart b/frontend/lib/theme/app_colors.dart new file mode 100644 index 0000000..dfd7a92 --- /dev/null +++ b/frontend/lib/theme/app_colors.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +@immutable +class AppColorExtension extends ThemeExtension { + const AppColorExtension({required this.success, required this.onSuccess}); + + final Color success; + final Color onSuccess; + + @override + AppColorExtension copyWith({Color? success, Color? onSuccess}) => + AppColorExtension( + success: success ?? this.success, + onSuccess: onSuccess ?? this.onSuccess, + ); + + @override + AppColorExtension lerp(AppColorExtension? other, double t) { + if (other is! AppColorExtension) return this; + return AppColorExtension( + success: Color.lerp(success, other.success, t)!, + onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!, + ); + } +} diff --git a/frontend/lib/theme/catppuccin_mocha_theme.dart b/frontend/lib/theme/catppuccin_mocha_theme.dart new file mode 100644 index 0000000..2a17cde --- /dev/null +++ b/frontend/lib/theme/catppuccin_mocha_theme.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +abstract final class CatppuccinMochaColors { + static const Color crust = Color(0xFF11111b); + static const Color mantle = Color(0xFF181825); + static const Color base = Color(0xFF1e1e2e); + static const Color surface0 = Color(0xFF313244); + static const Color surface1 = Color(0xFF45475a); + static const Color surface2 = Color(0xFF585b70); + static const Color overlay0 = Color(0xFF6c7086); + static const Color overlay1 = Color(0xFF7f849c); + static const Color overlay2 = Color(0xFF9399b2); + static const Color subtext0 = Color(0xFFa6adc8); + static const Color subtext1 = Color(0xFFbac2de); + static const Color text = Color(0xFFcdd6f4); + + static const Color rosewater = Color(0xFFf5e0dc); + static const Color flamingo = Color(0xFFf2cdcd); + static const Color pink = Color(0xFFf5c2e7); + static const Color mauve = Color(0xFFcba6f7); + static const Color red = Color(0xFFf38ba8); + static const Color maroon = Color(0xFFeba0ac); + static const Color peach = Color(0xFFfab387); + static const Color yellow = Color(0xFFf9e2af); + static const Color green = Color(0xFFa6e3a1); + static const Color teal = Color(0xFF94e2d5); + static const Color sky = Color(0xFF89dceb); + static const Color sapphire = Color(0xFF74c7ec); + static const Color blue = Color(0xFF89b4fa); + static const Color lavender = Color(0xFFb4befe); +} + +const ColorScheme _catppuccinMochaColorScheme = ColorScheme( + brightness: Brightness.dark, + + // Surfaces — AppBar (darkest) → NavRail → body (lightest) + surface: CatppuccinMochaColors.base, + surfaceContainerLowest: CatppuccinMochaColors.crust, + surfaceContainerLow: CatppuccinMochaColors.mantle, + surfaceContainer: CatppuccinMochaColors.surface0, + surfaceContainerHigh: CatppuccinMochaColors.surface1, + surfaceContainerHighest: CatppuccinMochaColors.surface2, + onSurface: CatppuccinMochaColors.text, + onSurfaceVariant: CatppuccinMochaColors.subtext1, + outline: CatppuccinMochaColors.overlay1, + outlineVariant: CatppuccinMochaColors.overlay0, + + // Primary — Save button, selected nav indicators + primary: CatppuccinMochaColors.mauve, + onPrimary: CatppuccinMochaColors.crust, + primaryContainer: CatppuccinMochaColors.surface0, + onPrimaryContainer: CatppuccinMochaColors.text, + + // Secondary — Test button (not-passing state) + secondary: CatppuccinMochaColors.blue, + onSecondary: CatppuccinMochaColors.crust, + secondaryContainer: CatppuccinMochaColors.surface1, + onSecondaryContainer: CatppuccinMochaColors.text, + + // Tertiary — swipe-to-unread background + tertiary: CatppuccinMochaColors.teal, + onTertiary: CatppuccinMochaColors.crust, + tertiaryContainer: CatppuccinMochaColors.surface0, + onTertiaryContainer: CatppuccinMochaColors.text, + + // Error + error: CatppuccinMochaColors.red, + onError: CatppuccinMochaColors.crust, + errorContainer: CatppuccinMochaColors.maroon, + onErrorContainer: CatppuccinMochaColors.crust, + + // Inverse / scrim + inverseSurface: CatppuccinMochaColors.text, + onInverseSurface: CatppuccinMochaColors.crust, + inversePrimary: CatppuccinMochaColors.mauve, + scrim: CatppuccinMochaColors.crust, + shadow: CatppuccinMochaColors.crust, +); + +final ThemeData catppuccinMochaDarkTheme = ThemeData( + colorScheme: _catppuccinMochaColorScheme, + extensions: [ + const AppColorExtension( + success: CatppuccinMochaColors.green, + onSuccess: CatppuccinMochaColors.crust, + ), + ], +); diff --git a/frontend/lib/theme/gruvbox_theme.dart b/frontend/lib/theme/gruvbox_theme.dart new file mode 100644 index 0000000..f414bd3 --- /dev/null +++ b/frontend/lib/theme/gruvbox_theme.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +abstract final class GruvboxColors { + static const Color bgHard = Color(0xFF1d2021); + static const Color bg = Color(0xFF282828); + static const Color bgSoft = Color(0xFF32302f); + static const Color bg1 = Color(0xFF3c3836); + static const Color bg2 = Color(0xFF504945); + static const Color bg3 = Color(0xFF665c54); + static const Color bg4 = Color(0xFF7c6f64); + static const Color fg = Color(0xFFebdbb2); + static const Color fg2 = Color(0xFFd5c4a1); + static const Color gray = Color(0xFF928374); + static const Color red = Color(0xFFcc241d); + static const Color brightRed = Color(0xFFfb4934); + static const Color green = Color(0xFF98971a); + static const Color brightGreen = Color(0xFFb8bb26); + static const Color yellow = Color(0xFFd79921); + static const Color brightYellow = Color(0xFFfabd2f); + static const Color blue = Color(0xFF458588); + static const Color brightBlue = Color(0xFF83a598); + static const Color purple = Color(0xFFb16286); + static const Color brightPurple = Color(0xFFd3869b); + static const Color aqua = Color(0xFF689d6a); + static const Color brightAqua = Color(0xFF8ec07c); + static const Color orange = Color(0xFFd65d0e); + static const Color brightOrange = Color(0xFFfe8019); +} + +const ColorScheme _gruvboxColorScheme = ColorScheme( + brightness: Brightness.dark, + + // Surfaces — AppBar (darkest) → NavRail → body (lightest) + surface: GruvboxColors.bgSoft, + surfaceContainerLowest: GruvboxColors.bgHard, + surfaceContainerLow: GruvboxColors.bg, + surfaceContainer: GruvboxColors.bg1, + surfaceContainerHigh: GruvboxColors.bg1, + surfaceContainerHighest: GruvboxColors.bg2, + onSurface: GruvboxColors.fg, + onSurfaceVariant: GruvboxColors.fg2, + outline: GruvboxColors.gray, + outlineVariant: GruvboxColors.bg4, + + // Primary — Save button, selected nav indicators + primary: GruvboxColors.brightOrange, + onPrimary: GruvboxColors.bgHard, + primaryContainer: GruvboxColors.bg1, + onPrimaryContainer: GruvboxColors.fg, + + // Secondary — Test button (not-passing state) + secondary: GruvboxColors.blue, + onSecondary: GruvboxColors.fg, + secondaryContainer: GruvboxColors.bg2, + onSecondaryContainer: GruvboxColors.fg, + + // Tertiary — swipe-to-unread background + tertiary: GruvboxColors.aqua, + onTertiary: GruvboxColors.bgHard, + tertiaryContainer: GruvboxColors.bg1, + onTertiaryContainer: GruvboxColors.fg, + + // Error + error: GruvboxColors.brightRed, + onError: GruvboxColors.bgHard, + errorContainer: GruvboxColors.red, + onErrorContainer: GruvboxColors.fg, + + // Inverse / scrim + inverseSurface: GruvboxColors.fg, + onInverseSurface: GruvboxColors.bgHard, + inversePrimary: GruvboxColors.orange, + scrim: GruvboxColors.bgHard, + shadow: GruvboxColors.bgHard, +); + +final ThemeData gruvboxDarkTheme = ThemeData( + colorScheme: _gruvboxColorScheme, + extensions: [ + const AppColorExtension( + success: GruvboxColors.brightGreen, + onSuccess: GruvboxColors.bgHard, + ), + ], +); diff --git a/frontend/lib/utils/ui_snackbars.dart b/frontend/lib/utils/ui_snackbars.dart index 22e0cb8..c504068 100644 --- a/frontend/lib/utils/ui_snackbars.dart +++ b/frontend/lib/utils/ui_snackbars.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; class UISnackbars { static void showError(BuildContext context, String message) { @@ -16,15 +17,16 @@ class UISnackbars { } static void showSuccess(BuildContext context, String message) { + final appColors = Theme.of(context).extension()!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( message, - style: TextStyle(color: Theme.of(context).colorScheme.onPrimary), + style: TextStyle(color: appColors.onSuccess), ), behavior: SnackBarBehavior.floating, showCloseIcon: true, - backgroundColor: Colors.lightGreen, + backgroundColor: appColors.success, ), ); }