From 66183ec0753e2814796c3b5ec72b739732c8512d Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 5 Mar 2026 17:59:59 +0100 Subject: [PATCH] add scale setting --- .../screens/appearance_display_settings.dart | 78 +++++++++++++++++++ .../user/data/models/general_settings.dart | 10 +++ .../user/data/models/general_settings.g.dart | 15 ++++ .../domain/repositories/general_settings.dart | 4 + .../repositories/general_settings.g.dart | 2 +- app/lib/main.dart | 6 ++ app/lib/presentation/main_app.dart | 78 ++++++++++++++++++- 7 files changed, 191 insertions(+), 2 deletions(-) diff --git a/app/lib/features/settings/presentation/screens/appearance_display_settings.dart b/app/lib/features/settings/presentation/screens/appearance_display_settings.dart index 9d949f82..a31eda40 100644 --- a/app/lib/features/settings/presentation/screens/appearance_display_settings.dart +++ b/app/lib/features/settings/presentation/screens/appearance_display_settings.dart @@ -19,6 +19,7 @@ */ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; @@ -63,12 +64,89 @@ class _VisualSection extends StatelessWidget { return const Column( children: [ SettingSection(name: 'Visual'), + _UiZoomSection(), _ThemeSection(), ], ); } } +class _UiZoomSection extends HookConsumerWidget { + const _UiZoomSection(); + + static final _sliderDivisions = + ((maxUiScaleFactor - minUiScaleFactor) / uiScaleFactorStep).round(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final uiScaleFactor = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.uiScaleFactor), + ); + final sliderValue = useState(uiScaleFactor); + + useEffect(() { + sliderValue.value = uiScaleFactor; + return null; + }, [uiScaleFactor]); + + final sliderLabel = '${(sliderValue.value * 100).round()}%'; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('User Interface Zoom'), + subtitle: Text('Make the user interface smaller or larger'), + leading: Icon(Icons.zoom_in), + contentPadding: EdgeInsets.zero, + ), + Row( + children: [ + Text(sliderLabel, style: Theme.of(context).textTheme.titleLarge), + Expanded( + child: Slider( + min: minUiScaleFactor, + max: maxUiScaleFactor, + divisions: _sliderDivisions, + label: sliderLabel, + value: sliderValue.value.clamp( + minUiScaleFactor, + maxUiScaleFactor, + ), + onChanged: (value) { + sliderValue.value = value; + }, + onChangeEnd: (value) async { + final normalized = _normalizeUiScale(value); + sliderValue.value = normalized; + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .uiScaleFactor(normalized), + ); + }, + ), + ), + ], + ), + ], + ), + ); + } +} + +double _normalizeUiScale(double value) { + final clampedValue = value.clamp(minUiScaleFactor, maxUiScaleFactor); + final stepIndex = ((clampedValue - minUiScaleFactor) / uiScaleFactorStep) + .round(); + final normalized = minUiScaleFactor + (stepIndex * uiScaleFactorStep); + return normalized.clamp(minUiScaleFactor, maxUiScaleFactor); +} + class _TabBarSection extends StatelessWidget { const _TabBarSection(); diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index d7f8b949..7b9f1df1 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -34,6 +34,11 @@ const _fallbackSearchProvider = BangKey( ); const _fallbackAutocompleteProvider = SearchSuggestionProviders.none; +const defaultUiScaleFactor = 1.0; +const minUiScaleFactor = 0.5; +const maxUiScaleFactor = 1.5; +const uiScaleFactorStep = 0.05; + enum TabBarSwipeAction { switchLastOpened, navigateOrderedTabs } enum QuickTabSwitcherMode { lastUsedTabs, containerTabs } @@ -62,6 +67,7 @@ enum DeleteBrowsingDataType { @JsonSerializable(includeIfNull: true, constructor: 'withDefaults') class GeneralSettings with FastEquatable { final ThemeMode themeMode; + final double uiScaleFactor; final bool enableReadability; final bool enforceReadability; final Set? deleteBrowsingDataOnQuit; @@ -110,6 +116,7 @@ class GeneralSettings with FastEquatable { GeneralSettings({ required this.themeMode, + required this.uiScaleFactor, required this.enableReadability, required this.enforceReadability, required this.deleteBrowsingDataOnQuit, @@ -157,6 +164,7 @@ class GeneralSettings with FastEquatable { GeneralSettings.withDefaults({ ThemeMode? themeMode, + double? uiScaleFactor, bool? enableReadability, bool? enforceReadability, this.deleteBrowsingDataOnQuit, @@ -201,6 +209,7 @@ class GeneralSettings with FastEquatable { bool? unshortenerEnabled, String? unshortenerToken, }) : themeMode = themeMode ?? ThemeMode.dark, + uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, enableReadability = enableReadability ?? true, enforceReadability = enforceReadability ?? false, defaultSearchProvider = defaultSearchProvider ?? _fallbackSearchProvider, @@ -278,6 +287,7 @@ class GeneralSettings with FastEquatable { @override List get hashParameters => [ themeMode, + uiScaleFactor, enableReadability, enforceReadability, deleteBrowsingDataOnQuit, diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index 7dd0d31d..f5835e4a 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -9,6 +9,8 @@ part of 'general_settings.dart'; abstract class _$GeneralSettingsCWProxy { GeneralSettings themeMode(ThemeMode themeMode); + GeneralSettings uiScaleFactor(double uiScaleFactor); + GeneralSettings enableReadability(bool enableReadability); GeneralSettings enforceReadability(bool enforceReadability); @@ -122,6 +124,7 @@ abstract class _$GeneralSettingsCWProxy { /// ``` GeneralSettings call({ ThemeMode themeMode, + double uiScaleFactor, bool enableReadability, bool enforceReadability, Set? deleteBrowsingDataOnQuit, @@ -178,6 +181,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { @override GeneralSettings themeMode(ThemeMode themeMode) => call(themeMode: themeMode); + @override + GeneralSettings uiScaleFactor(double uiScaleFactor) => + call(uiScaleFactor: uiScaleFactor); + @override GeneralSettings enableReadability(bool enableReadability) => call(enableReadability: enableReadability); @@ -373,6 +380,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { /// ``` GeneralSettings call({ Object? themeMode = const $CopyWithPlaceholder(), + Object? uiScaleFactor = const $CopyWithPlaceholder(), Object? enableReadability = const $CopyWithPlaceholder(), Object? enforceReadability = const $CopyWithPlaceholder(), Object? deleteBrowsingDataOnQuit = const $CopyWithPlaceholder(), @@ -423,6 +431,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.themeMode // ignore: cast_nullable_to_non_nullable : themeMode as ThemeMode, + uiScaleFactor: + uiScaleFactor == const $CopyWithPlaceholder() || uiScaleFactor == null + ? _value.uiScaleFactor + // ignore: cast_nullable_to_non_nullable + : uiScaleFactor as double, enableReadability: enableReadability == const $CopyWithPlaceholder() || enableReadability == null @@ -697,6 +710,7 @@ GeneralSettings _$GeneralSettingsFromJson( Map json, ) => GeneralSettings.withDefaults( themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']), + uiScaleFactor: (json['uiScaleFactor'] as num?)?.toDouble(), enableReadability: json['enableReadability'] as bool?, enforceReadability: json['enforceReadability'] as bool?, deleteBrowsingDataOnQuit: (json['deleteBrowsingDataOnQuit'] as List?) @@ -784,6 +798,7 @@ Map _$GeneralSettingsToJson( GeneralSettings instance, ) => { 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, + 'uiScaleFactor': instance.uiScaleFactor, 'enableReadability': instance.enableReadability, 'enforceReadability': instance.enforceReadability, 'deleteBrowsingDataOnQuit': instance.deleteBrowsingDataOnQuit diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart index 4a13efa7..dd3b591b 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -48,6 +48,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.string, db.typeMapping, ), + 'uiScaleFactor': settings['uiScaleFactor']?.readAs( + DriftSqlType.double, + db.typeMapping, + ), 'enableReadability': settings['enableReadability']?.readAs( DriftSqlType.bool, db.typeMapping, diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart index 63cf633c..d9b2e649 100644 --- a/app/lib/features/user/domain/repositories/general_settings.g.dart +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'23b15af79f0f41771c20f7c0f7e94f93659ee1e8'; + r'2098a12825b94a9b39d2e24f90a6afaab47fe3c7'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/app/lib/main.dart b/app/lib/main.dart index 9a53901d..0c0a570b 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -128,6 +128,11 @@ class _MainWidget extends HookConsumerWidget { final themeMode = ref.watch( generalSettingsWithDefaultsProvider.select((value) => value.themeMode), ); + final uiScaleFactor = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.uiScaleFactor, + ), + ); useOnInitialization(() async { await CountryCodes.init(); @@ -255,6 +260,7 @@ class _MainWidget extends HookConsumerWidget { extensions: const >[AppColors.dark], ), themeMode: themeMode, + uiScaleFactor: uiScaleFactor, ); }, ); diff --git a/app/lib/presentation/main_app.dart b/app/lib/presentation/main_app.dart index 70172544..b44d89c6 100644 --- a/app/lib/presentation/main_app.dart +++ b/app/lib/presentation/main_app.dart @@ -30,11 +30,13 @@ class MainApp extends HookConsumerWidget { final ThemeData? theme; final ThemeData? darkTheme; final ThemeMode? themeMode; + final double uiScaleFactor; const MainApp({ required this.theme, required this.darkTheme, required this.themeMode, + required this.uiScaleFactor, super.key, }); @@ -51,6 +53,12 @@ class MainApp extends HookConsumerWidget { theme: theme, darkTheme: darkTheme, themeMode: themeMode, + builder: (context, child) { + return _AppUiScale( + uiScaleFactor: uiScaleFactor, + child: child ?? const SizedBox.shrink(), + ); + }, home: Scaffold( body: Center( child: Column( @@ -76,7 +84,12 @@ class MainApp extends HookConsumerWidget { themeMode: themeMode, routerConfig: router.value, builder: (context, child) { - return _SyncEventListener(child: child ?? const SizedBox.shrink()); + return _AppUiScale( + uiScaleFactor: uiScaleFactor, + child: _SyncEventListener( + child: child ?? const SizedBox.shrink(), + ), + ); }, ); }, @@ -86,6 +99,12 @@ class MainApp extends HookConsumerWidget { theme: theme, darkTheme: darkTheme, themeMode: themeMode, + builder: (context, child) { + return _AppUiScale( + uiScaleFactor: uiScaleFactor, + child: child ?? const SizedBox.shrink(), + ); + }, home: Scaffold( appBar: AppBar(title: const Text('Initiallization Error')), body: Center( @@ -106,6 +125,63 @@ class MainApp extends HookConsumerWidget { } } +class _AppUiScale extends StatelessWidget { + final double uiScaleFactor; + final Widget child; + + const _AppUiScale({required this.uiScaleFactor, required this.child}); + + @override + Widget build(BuildContext context) { + if (uiScaleFactor == 1.0) { + return child; + } + + final mediaQuery = MediaQuery.of(context); + + return MediaQuery( + data: mediaQuery.copyWith( + textScaler: _AppTextScaler( + baseTextScaler: mediaQuery.textScaler, + uiScaleFactor: uiScaleFactor, + ), + ), + child: child, + ); + } +} + +class _AppTextScaler extends TextScaler { + final TextScaler baseTextScaler; + final double uiScaleFactor; + + const _AppTextScaler({ + required this.baseTextScaler, + required this.uiScaleFactor, + }) : assert(uiScaleFactor > 0); + + @override + double scale(double fontSize) => + baseTextScaler.scale(fontSize) * uiScaleFactor; + + @override + double get textScaleFactor => scale(1.0); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is _AppTextScaler && + baseTextScaler == other.baseTextScaler && + uiScaleFactor == other.uiScaleFactor; + } + + @override + int get hashCode => Object.hash(baseTextScaler, uiScaleFactor); +} + class _SyncEventListener extends ConsumerWidget { final Widget child;