From 46a4129d85c846b9185d5e03d2abd2ab271900d6 Mon Sep 17 00:00:00 2001 From: Enrique Lozano Cebriano <61509169+enrique-lozano@users.noreply.github.com> Date: Thu, 9 Jan 2025 19:04:03 +0100 Subject: [PATCH] feat: Theme dropdown and setting icons animations --- .../settings/appearance_settings_page.dart | 148 +++++++--------- .../settings/widgets/monekin_tile_switch.dart | 4 +- .../utils/get_theme_from_string.dart | 34 +++- .../widgets/monekin_dropdown_select.dart | 163 ++++++++++++++++++ lib/i18n/strings_de.json | 2 +- lib/i18n/strings_en.json | 2 +- lib/i18n/strings_es.json | 2 +- lib/i18n/strings_hu.json | 2 +- lib/i18n/strings_uk.json | 2 +- lib/i18n/strings_zh-TW.json | 2 +- lib/i18n/translations.g.dart | 26 +-- 11 files changed, 276 insertions(+), 111 deletions(-) create mode 100644 lib/core/presentation/widgets/monekin_dropdown_select.dart diff --git a/lib/app/settings/appearance_settings_page.dart b/lib/app/settings/appearance_settings_page.dart index 9201b9d4..482936ae 100644 --- a/lib/app/settings/appearance_settings_page.dart +++ b/lib/app/settings/appearance_settings_page.dart @@ -4,9 +4,13 @@ import 'package:monekin/app/settings/widgets/monekin_tile_switch.dart'; import 'package:monekin/app/settings/widgets/supported_locales.dart'; import 'package:monekin/core/database/services/user-setting/private_mode_service.dart'; import 'package:monekin/core/database/services/user-setting/user_setting_service.dart'; +import 'package:monekin/core/database/services/user-setting/utils/get_theme_from_string.dart'; import 'package:monekin/core/extensions/color.extensions.dart'; +import 'package:monekin/core/presentation/animations/scaled_animated_switcher.dart'; +import 'package:monekin/core/presentation/theme.dart'; import 'package:monekin/core/presentation/widgets/color_picker/color_picker.dart'; import 'package:monekin/core/presentation/widgets/color_picker/color_picker_modal.dart'; +import 'package:monekin/core/presentation/widgets/monekin_dropdown_select.dart'; import 'package:monekin/i18n/translations.g.dart'; import '../../core/presentation/app_colors.dart'; @@ -33,70 +37,6 @@ class SelectItem { } class _AdvancedSettingsPageState extends State { - Widget buildSelector({ - required String title, - String? dialogDescr, - required List> items, - required T selected, - required void Function(T newValue) onChanged, - }) { - SelectItem selectedItem = - items.firstWhere((element) => element.value == selected); - - return ListTile( - title: Text(title), - subtitle: Text(selectedItem.label), - leading: Icon(Icons.light_mode), - onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title), - if (dialogDescr != null) ...[ - const SizedBox(height: 8), - Text( - dialogDescr, - style: Theme.of(context).textTheme.labelMedium, - ) - ] - ], - ), - contentPadding: const EdgeInsets.only(top: 12), - content: SingleChildScrollView( - child: StatefulBuilder(builder: (context, alertState) { - return Column( - children: items - .map( - (item) => RadioListTile( - title: Text(item.label), - value: item.value, - groupValue: selected, - onChanged: (newValue) { - if (newValue != null && newValue != selected) { - onChanged(newValue); - selected = newValue; - } - }, - ), - ) - .toList()); - })), - actions: [ - TextButton( - child: Text(t.general.cancel), - onPressed: () { - Navigator.pop(context); - }, - ), - ], - ), - ); - }); - } - @override Widget build(BuildContext context) { final t = Translations.of(context); @@ -155,33 +95,32 @@ class _AdvancedSettingsPageState extends State { ), createListSeparator(context, t.settings.theme_and_colors), StreamBuilder( - stream: UserSettingService.instance - .getSettingFromDB(SettingKey.themeMode), - builder: (context, snapshot) { - return buildSelector( - title: t.settings.theme, - items: [ - SelectItem(value: 'system', label: t.settings.theme_auto), - SelectItem(value: 'light', label: t.settings.theme_light), - SelectItem(value: 'dark', label: t.settings.theme_dark) + stream: UserSettingService.instance + .getSettingFromDB(SettingKey.themeMode), + builder: (context, snapshot) { + final theme = getThemeFromString(snapshot.data); + + return ListTile( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: Text(t.settings.theme)), + const SizedBox(width: 12), + Flexible(child: _buildThemeDropdown(theme)) ], - selected: snapshot.data ?? 'system', - onChanged: (value) { - UserSettingService.instance - .setItem( - SettingKey.themeMode, - value, - updateGlobalState: true, - ) - .then((value) => null); - }, - ); - }), + ), + leading: ScaledAnimatedSwitcher( + keyToWatch: theme.icon(context).toString(), + child: Icon(theme.icon(context)), + ), + ); + }, + ), MonekinTileSwitch( title: t.settings.amoled_mode, subtitle: t.settings.amoled_mode_descr, initialValue: appStateSettings[SettingKey.amoledMode] == '1', - disabled: Theme.of(context).brightness == Brightness.light, + disabled: isAppInLightBrightness(context), onSwitchDebounceMs: 200, onSwitch: (bool value) async { await UserSettingService.instance.setItem( @@ -265,7 +204,7 @@ class _AdvancedSettingsPageState extends State { MonekinTileSwitch( title: t.settings.security.private_mode_at_launch, subtitle: t.settings.security.private_mode_at_launch_descr, - icon: Icons.phonelink_lock_outlined, + icon: const Icon(Icons.phonelink_lock_outlined), initialValue: appStateSettings[SettingKey.privateModeAtLaunch] == '1', onSwitch: (bool value) async { @@ -275,11 +214,18 @@ class _AdvancedSettingsPageState extends State { StreamBuilder( stream: PrivateModeService.instance.privateModeStream, builder: (context, snapshot) { + final initialValue = (snapshot.data ?? false); + return MonekinTileSwitch( title: t.settings.security.private_mode, subtitle: t.settings.security.private_mode_descr, - icon: Icons.lock, - initialValue: snapshot.data ?? false, + icon: ScaledAnimatedSwitcher( + keyToWatch: initialValue.toString(), + child: Icon(initialValue + ? Icons.lock_outline_rounded + : Icons.lock_open_rounded), + ), + initialValue: initialValue, onSwitch: (bool value) { setState(() { PrivateModeService.instance.setPrivateMode(value); @@ -292,4 +238,28 @@ class _AdvancedSettingsPageState extends State { ), ); } + + Widget _buildThemeDropdown(ThemeMode theme) { + return LayoutBuilder(builder: (context, constraints) { + return MonekinDropdownSelect( + initial: theme, + compact: true, + expanded: false, + items: const [ + ThemeMode.system, + ThemeMode.light, + ThemeMode.dark, + ], + getLabel: (x) => x.displayName(context), + onChanged: (mode) { + UserSettingService.instance + .setItem( + SettingKey.themeMode, + mode.name, + updateGlobalState: true, + ) + .then((value) => null); + }); + }); + } } diff --git a/lib/app/settings/widgets/monekin_tile_switch.dart b/lib/app/settings/widgets/monekin_tile_switch.dart index 1e3ba626..8eee7497 100644 --- a/lib/app/settings/widgets/monekin_tile_switch.dart +++ b/lib/app/settings/widgets/monekin_tile_switch.dart @@ -21,7 +21,7 @@ class MonekinTileSwitch extends StatefulWidget { final String title; final String? subtitle; final bool initialValue; - final IconData? icon; + final Widget? icon; /// Callback triggered when the switch value changes. /// @@ -98,7 +98,7 @@ class _MonekinTileSwitchState extends State { return SwitchListTile( title: Text(widget.title), subtitle: widget.subtitle == null ? null : Text(widget.subtitle!), - secondary: widget.icon == null ? null : Icon(widget.icon), + secondary: widget.icon, value: value, onChanged: widget.disabled ? null diff --git a/lib/core/database/services/user-setting/utils/get_theme_from_string.dart b/lib/core/database/services/user-setting/utils/get_theme_from_string.dart index d54ff6f4..c479a98f 100644 --- a/lib/core/database/services/user-setting/utils/get_theme_from_string.dart +++ b/lib/core/database/services/user-setting/utils/get_theme_from_string.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:monekin/core/presentation/theme.dart'; +import 'package:monekin/i18n/translations.g.dart'; + +ThemeMode getThemeFromString(String? themeString) { + if (themeString == null) return ThemeMode.system; -ThemeMode getThemeFromString(String themeString) { Map themeSetting = { 'system': ThemeMode.system, 'light': ThemeMode.light, @@ -9,3 +13,31 @@ ThemeMode getThemeFromString(String themeString) { return themeSetting[themeString] ?? ThemeMode.system; } + +extension ThemeModeExt on ThemeMode { + String displayName(BuildContext context) { + final t = Translations.of(context); + + switch (this) { + case ThemeMode.system: + return t.settings.theme_auto; + case ThemeMode.light: + return t.settings.theme_light; + case ThemeMode.dark: + return t.settings.theme_dark; + } + } + + IconData icon(BuildContext context) { + switch (this) { + case ThemeMode.system: + return isAppInDarkBrightness(context) + ? Icons.dark_mode + : Icons.light_mode_rounded; + case ThemeMode.light: + return Icons.light_mode_rounded; + case ThemeMode.dark: + return Icons.dark_mode; + } + } +} diff --git a/lib/core/presentation/widgets/monekin_dropdown_select.dart b/lib/core/presentation/widgets/monekin_dropdown_select.dart new file mode 100644 index 00000000..af4ec385 --- /dev/null +++ b/lib/core/presentation/widgets/monekin_dropdown_select.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; + +class MonekinDropdownSelect extends StatefulWidget { + const MonekinDropdownSelect( + {super.key, + required this.initial, + required this.items, + required this.onChanged, + this.backgroundColor, + this.compact = false, + this.checkInitialValue = false, + this.getLabel, + this.isDisabled, + this.enabled = true, + this.expanded = false, + this.textConstraints = const BoxConstraints()}); + + final T initial; + final List items; + final void Function(T) onChanged; + final Color? backgroundColor; + final bool compact; + final bool enabled; + final bool expanded; + final bool checkInitialValue; + final String Function(T)? getLabel; + final bool Function(T)? isDisabled; + final BoxConstraints textConstraints; + + @override + State createState() => + _MonekinDropdownSelectState(); +} + +class _MonekinDropdownSelectState extends State> { + T? currentValue; + + late final GlobalKey _dropdownButtonKey = GlobalKey(); + + void openDropdown() { + GestureDetector? detector; + void searchForGestureDetector(BuildContext? element) { + element?.visitChildElements((element) { + if (element.widget is GestureDetector) { + detector = element.widget as GestureDetector?; + } else { + searchForGestureDetector(element); + } + }); + } + + searchForGestureDetector(_dropdownButtonKey.currentContext); + assert(detector != null); + + detector?.onTap?.call(); + } + + @override + void initState() { + super.initState(); + + if (widget.checkInitialValue == true && + !widget.items.contains(widget.initial)) { + currentValue = + widget.items.where((item) => !_isItemDisabled(item)).firstOrNull ?? + widget.initial; + } else { + currentValue = widget.initial; + } + } + + bool _isItemDisabled(T item) { + return widget.isDisabled == null ? false : widget.isDisabled!(item); + } + + @override + Widget build(BuildContext context) { + return SelectorContainer( + backgroundColor: widget.backgroundColor, + padding: EdgeInsetsDirectional.only( + start: widget.compact ? 13 : 15, + end: widget.compact ? 1 : 6, + top: widget.compact ? 2 : 10, + bottom: widget.compact ? 2 : 10), + enabled: widget.enabled, + child: DropdownButton( + key: _dropdownButtonKey, + underline: Container(), + dropdownColor: widget.backgroundColor ?? + Theme.of(context).colorScheme.surfaceContainerHigh, + isDense: true, + isExpanded: widget.expanded, + value: currentValue ?? widget.initial, + elevation: 15, + iconSize: 32, + borderRadius: BorderRadius.circular(10), + icon: const Icon(Icons.arrow_drop_down_rounded), + onChanged: !widget.enabled + ? null + : (T? value) { + widget.onChanged(value ?? widget.items[0]); + setState(() { + currentValue = value; + }); + }, + items: + widget.items.toSet().toList().map>((T value) { + return DropdownMenuItem( + alignment: AlignmentDirectional.centerStart, + enabled: !_isItemDisabled(value), + value: value, + child: ConstrainedBox( + constraints: widget.textConstraints, + child: Text( + widget.getLabel != null + ? widget.getLabel!(value) + : value.toString(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity( + _isItemDisabled(value) || !widget.enabled ? 0.3 : 1), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +class SelectorContainer extends StatelessWidget { + const SelectorContainer({ + super.key, + required this.padding, + this.enabled = true, + this.backgroundColor, + required this.child, + }); + + final EdgeInsetsGeometry padding; + final bool enabled; + final Color? backgroundColor; + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: backgroundColor ?? + Theme.of(context) + .colorScheme + .surfaceContainerHigh + .withOpacity(enabled ? 1 : 0.75), + borderRadius: BorderRadiusDirectional.circular(10), + ), + child: child, + ); + } +} diff --git a/lib/i18n/strings_de.json b/lib/i18n/strings_de.json index 11309418..2a11f0f5 100644 --- a/lib/i18n/strings_de.json +++ b/lib/i18n/strings_de.json @@ -665,7 +665,7 @@ "first_day_of_week": "Erster Tag der Woche", "theme_and_colors": "Thema und Farben", "theme": "Thema", - "theme.auto": "Vom System definiert", + "theme.auto": "System", "theme.light": "Hell", "theme.dark": "Dunkel", "amoled-mode": "AMOLED-Modus", diff --git a/lib/i18n/strings_en.json b/lib/i18n/strings_en.json index 05c6b95b..0687f2e6 100644 --- a/lib/i18n/strings_en.json +++ b/lib/i18n/strings_en.json @@ -653,7 +653,7 @@ "first_day_of_week": "First day of week", "theme_and_colors": "Theme and colors", "theme": "Theme", - "theme.auto": "Defined by the system", + "theme.auto": "System", "theme.light": "Light", "theme.dark": "Dark", "amoled-mode": "AMOLED mode", diff --git a/lib/i18n/strings_es.json b/lib/i18n/strings_es.json index 8672d877..6952017b 100644 --- a/lib/i18n/strings_es.json +++ b/lib/i18n/strings_es.json @@ -660,7 +660,7 @@ "first_day_of_week": "Primer día de la semana", "theme_and_colors": "Tema y colores", "theme": "Tema", - "theme.auto": "Definido por el sistema", + "theme.auto": "Sistema", "theme.light": "Claro", "theme.dark": "Oscuro", "amoled-mode": "Modo AMOLED", diff --git a/lib/i18n/strings_hu.json b/lib/i18n/strings_hu.json index d6c8b051..37f4dd5e 100644 --- a/lib/i18n/strings_hu.json +++ b/lib/i18n/strings_hu.json @@ -656,7 +656,7 @@ "first_day_of_week": "A hét első napja", "theme_and_colors": "Témák és színek", "theme": "Téma", - "theme.auto": "A rendszer határozza meg", + "theme.auto": "Rendszer", "theme.light": "Világos", "theme.dark": "Sötét", "amoled-mode": "AMOLED mód", diff --git a/lib/i18n/strings_uk.json b/lib/i18n/strings_uk.json index b06cfe9e..186129e8 100644 --- a/lib/i18n/strings_uk.json +++ b/lib/i18n/strings_uk.json @@ -655,7 +655,7 @@ "first_day_of_week": "Перший день тижня", "theme_and_colors": "Тема та кольори", "theme": "Тема", - "theme.auto": "Визначено системою", + "theme.auto": "система", "theme.light": "Світла", "theme.dark": "Темна", "amoled-mode": "Режим AMOLED", diff --git a/lib/i18n/strings_zh-TW.json b/lib/i18n/strings_zh-TW.json index bd53a98c..7b016b0a 100644 --- a/lib/i18n/strings_zh-TW.json +++ b/lib/i18n/strings_zh-TW.json @@ -653,7 +653,7 @@ "first_day_of_week": "一週的第一天", "theme_and_colors": "主題和顏色", "theme": "主題", - "theme.auto": "由系統定義", + "theme.auto": "系統", "theme.light": "明亮主題", "theme.dark": "黑暗主題", "amoled-mode": "amoled mode", diff --git a/lib/i18n/translations.g.dart b/lib/i18n/translations.g.dart index e9ae175c..e571c104 100644 --- a/lib/i18n/translations.g.dart +++ b/lib/i18n/translations.g.dart @@ -6,7 +6,7 @@ /// Locales: 6 /// Strings: 3313 (552 per locale) /// -/// Built on 2025-01-09 at 14:29 UTC +/// Built on 2025-01-09 at 17:53 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -567,7 +567,7 @@ class _TranslationsSettingsEn { String get first_day_of_week => 'First day of week'; String get theme_and_colors => 'Theme and colors'; String get theme => 'Theme'; - String get theme_auto => 'Defined by the system'; + String get theme_auto => 'System'; String get theme_light => 'Light'; String get theme_dark => 'Dark'; String get amoled_mode => 'AMOLED mode'; @@ -1881,7 +1881,7 @@ class _TranslationsSettingsDe implements _TranslationsSettingsEn { @override String get first_day_of_week => 'Erster Tag der Woche'; @override String get theme_and_colors => 'Thema und Farben'; @override String get theme => 'Thema'; - @override String get theme_auto => 'Vom System definiert'; + @override String get theme_auto => 'System'; @override String get theme_light => 'Hell'; @override String get theme_dark => 'Dunkel'; @override String get amoled_mode => 'AMOLED-Modus'; @@ -3195,7 +3195,7 @@ class _TranslationsSettingsEs implements _TranslationsSettingsEn { @override String get first_day_of_week => 'Primer día de la semana'; @override String get theme_and_colors => 'Tema y colores'; @override String get theme => 'Tema'; - @override String get theme_auto => 'Definido por el sistema'; + @override String get theme_auto => 'Sistema'; @override String get theme_light => 'Claro'; @override String get theme_dark => 'Oscuro'; @override String get amoled_mode => 'Modo AMOLED'; @@ -4510,7 +4510,7 @@ class _TranslationsSettingsHu implements _TranslationsSettingsEn { @override String get first_day_of_week => 'A hét első napja'; @override String get theme_and_colors => 'Témák és színek'; @override String get theme => 'Téma'; - @override String get theme_auto => 'A rendszer határozza meg'; + @override String get theme_auto => 'Rendszer'; @override String get theme_light => 'Világos'; @override String get theme_dark => 'Sötét'; @override String get amoled_mode => 'AMOLED mód'; @@ -5824,7 +5824,7 @@ class _TranslationsSettingsUk implements _TranslationsSettingsEn { @override String get first_day_of_week => 'Перший день тижня'; @override String get theme_and_colors => 'Тема та кольори'; @override String get theme => 'Тема'; - @override String get theme_auto => 'Визначено системою'; + @override String get theme_auto => 'система'; @override String get theme_light => 'Світла'; @override String get theme_dark => 'Темна'; @override String get amoled_mode => 'Режим AMOLED'; @@ -7138,7 +7138,7 @@ class _TranslationsSettingsZhTw implements _TranslationsSettingsEn { @override String get first_day_of_week => '一週的第一天'; @override String get theme_and_colors => '主題和顏色'; @override String get theme => '主題'; - @override String get theme_auto => '由系統定義'; + @override String get theme_auto => '系統'; @override String get theme_light => '明亮主題'; @override String get theme_dark => '黑暗主題'; @override String get amoled_mode => 'amoled mode'; @@ -8601,7 +8601,7 @@ extension on Translations { case 'settings.first_day_of_week': return 'First day of week'; case 'settings.theme_and_colors': return 'Theme and colors'; case 'settings.theme': return 'Theme'; - case 'settings.theme_auto': return 'Defined by the system'; + case 'settings.theme_auto': return 'System'; case 'settings.theme_light': return 'Light'; case 'settings.theme_dark': return 'Dark'; case 'settings.amoled_mode': return 'AMOLED mode'; @@ -9233,7 +9233,7 @@ extension on _TranslationsDe { case 'settings.first_day_of_week': return 'Erster Tag der Woche'; case 'settings.theme_and_colors': return 'Thema und Farben'; case 'settings.theme': return 'Thema'; - case 'settings.theme_auto': return 'Vom System definiert'; + case 'settings.theme_auto': return 'System'; case 'settings.theme_light': return 'Hell'; case 'settings.theme_dark': return 'Dunkel'; case 'settings.amoled_mode': return 'AMOLED-Modus'; @@ -9866,7 +9866,7 @@ extension on _TranslationsEs { case 'settings.first_day_of_week': return 'Primer día de la semana'; case 'settings.theme_and_colors': return 'Tema y colores'; case 'settings.theme': return 'Tema'; - case 'settings.theme_auto': return 'Definido por el sistema'; + case 'settings.theme_auto': return 'Sistema'; case 'settings.theme_light': return 'Claro'; case 'settings.theme_dark': return 'Oscuro'; case 'settings.amoled_mode': return 'Modo AMOLED'; @@ -10498,7 +10498,7 @@ extension on _TranslationsHu { case 'settings.first_day_of_week': return 'A hét első napja'; case 'settings.theme_and_colors': return 'Témák és színek'; case 'settings.theme': return 'Téma'; - case 'settings.theme_auto': return 'A rendszer határozza meg'; + case 'settings.theme_auto': return 'Rendszer'; case 'settings.theme_light': return 'Világos'; case 'settings.theme_dark': return 'Sötét'; case 'settings.amoled_mode': return 'AMOLED mód'; @@ -11130,7 +11130,7 @@ extension on _TranslationsUk { case 'settings.first_day_of_week': return 'Перший день тижня'; case 'settings.theme_and_colors': return 'Тема та кольори'; case 'settings.theme': return 'Тема'; - case 'settings.theme_auto': return 'Визначено системою'; + case 'settings.theme_auto': return 'система'; case 'settings.theme_light': return 'Світла'; case 'settings.theme_dark': return 'Темна'; case 'settings.amoled_mode': return 'Режим AMOLED'; @@ -11762,7 +11762,7 @@ extension on _TranslationsZhTw { case 'settings.first_day_of_week': return '一週的第一天'; case 'settings.theme_and_colors': return '主題和顏色'; case 'settings.theme': return '主題'; - case 'settings.theme_auto': return '由系統定義'; + case 'settings.theme_auto': return '系統'; case 'settings.theme_light': return '明亮主題'; case 'settings.theme_dark': return '黑暗主題'; case 'settings.amoled_mode': return 'amoled mode';