Add theme selection to settings page

Adds a dropdown in the settings page to switch between available themes
(Catppuccin Mocha, Gruvbox Dark). The selected theme is applied immediately
on save and persisted across restarts via SharedPreferences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-27 11:38:16 -04:00
co-authored by Claude Sonnet 4.6
parent 527e2bd6c8
commit a647e0d061
6 changed files with 280 additions and 15 deletions
+29 -10
View File
@@ -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<AppState>(
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();
}
}
+47 -3
View File
@@ -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<Settings> {
final _apiKeyController = TextEditingController();
bool _apiKeyVisible = false;
bool _testOk = false;
bool _connectionModified = false;
late String _selectedTheme;
final _formKey = GlobalKey<FormState>();
@@ -40,6 +45,7 @@ class _SettingsState extends State<Settings> {
_apiKeyController.text = XOR().xorDecode(
PrefUtil.getValue("api_key", "") as String,
);
_selectedTheme = context.read<AppState>().themeKey;
_isLoading = false;
setState(() {});
}
@@ -66,6 +72,7 @@ class _SettingsState extends State<Settings> {
onChanged: (text) {
setState(() {
_testOk = false;
_connectionModified = true;
});
},
validator: (value) {
@@ -87,6 +94,7 @@ class _SettingsState extends State<Settings> {
onChanged: (text) {
setState(() {
_testOk = false;
_connectionModified = true;
});
},
@@ -115,6 +123,32 @@ class _SettingsState extends State<Settings> {
),
),
SizedBox(height: 16),
// Theme selector
DropdownButtonFormField<String>(
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<Settings> {
setState(() {
_testOk = testResult == null;
if (testResult == null)
_connectionModified = false;
});
if (testResult == null) {
@@ -145,10 +181,14 @@ class _SettingsState extends State<Settings> {
: Icon(Icons.play_arrow),
style: ElevatedButton.styleFrom(
backgroundColor: _testOk
? Colors.lightGreen
? Theme.of(
context,
).extension<AppColorExtension>()!.success
: Theme.of(context).colorScheme.secondary,
foregroundColor: _testOk
? Theme.of(context).colorScheme.onPrimary
? Theme.of(
context,
).extension<AppColorExtension>()!.onSuccess
: Theme.of(context).colorScheme.onSecondary,
),
),
@@ -156,7 +196,8 @@ class _SettingsState extends State<Settings> {
// 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<Settings> {
_isSaving = true;
});
_saveData();
context.read<AppState>().setTheme(
_selectedTheme,
);
setState(() {
_isSaving = false;
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
@immutable
class AppColorExtension extends ThemeExtension<AppColorExtension> {
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)!,
);
}
}
@@ -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,
),
],
);
+86
View File
@@ -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,
),
],
);
+4 -2
View File
@@ -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<AppColorExtension>()!;
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,
),
);
}