From 9700b3868a4031a5457de72f135a8420a78b007d Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 9 Mar 2026 16:17:46 +0100 Subject: [PATCH] introduced setting to request disable animations --- .../screens/appearance_display_settings.dart | 27 +++++++++ .../user/data/models/general_settings.dart | 5 ++ .../user/data/models/general_settings.g.dart | 16 +++++ .../domain/repositories/general_settings.dart | 4 ++ .../repositories/general_settings.g.dart | 2 +- app/lib/main.dart | 6 ++ app/lib/presentation/main_app.dart | 60 ++++++++++++++----- app/test/presentation/main_app_test.dart | 60 +++++++++++++++++++ 8 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 app/test/presentation/main_app_test.dart 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 a31eda40..0008f92c 100644 --- a/app/lib/features/settings/presentation/screens/appearance_display_settings.dart +++ b/app/lib/features/settings/presentation/screens/appearance_display_settings.dart @@ -65,6 +65,7 @@ class _VisualSection extends StatelessWidget { children: [ SettingSection(name: 'Visual'), _UiZoomSection(), + _DisableAnimationsTile(), _ThemeSection(), ], ); @@ -147,6 +148,32 @@ double _normalizeUiScale(double value) { return normalized.clamp(minUiScaleFactor, maxUiScaleFactor); } +class _DisableAnimationsTile extends HookConsumerWidget { + const _DisableAnimationsTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final disableAnimations = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.disableAnimations), + ); + + return SwitchListTile.adaptive( + title: const Text('Disable Animations'), + subtitle: const Text('Reduce motion and turn off app animations'), + secondary: const Icon(Icons.animation), + value: disableAnimations, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.disableAnimations(value), + ); + }, + ); + } +} + 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 7b9f1df1..73b4073a 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -68,6 +68,7 @@ enum DeleteBrowsingDataType { class GeneralSettings with FastEquatable { final ThemeMode themeMode; final double uiScaleFactor; + final bool disableAnimations; final bool enableReadability; final bool enforceReadability; final Set? deleteBrowsingDataOnQuit; @@ -117,6 +118,7 @@ class GeneralSettings with FastEquatable { GeneralSettings({ required this.themeMode, required this.uiScaleFactor, + required this.disableAnimations, required this.enableReadability, required this.enforceReadability, required this.deleteBrowsingDataOnQuit, @@ -165,6 +167,7 @@ class GeneralSettings with FastEquatable { GeneralSettings.withDefaults({ ThemeMode? themeMode, double? uiScaleFactor, + bool? disableAnimations, bool? enableReadability, bool? enforceReadability, this.deleteBrowsingDataOnQuit, @@ -210,6 +213,7 @@ class GeneralSettings with FastEquatable { String? unshortenerToken, }) : themeMode = themeMode ?? ThemeMode.dark, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, + disableAnimations = disableAnimations ?? false, enableReadability = enableReadability ?? true, enforceReadability = enforceReadability ?? false, defaultSearchProvider = defaultSearchProvider ?? _fallbackSearchProvider, @@ -288,6 +292,7 @@ class GeneralSettings with FastEquatable { List get hashParameters => [ themeMode, uiScaleFactor, + disableAnimations, 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 f5835e4a..05f82084 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -11,6 +11,8 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings uiScaleFactor(double uiScaleFactor); + GeneralSettings disableAnimations(bool disableAnimations); + GeneralSettings enableReadability(bool enableReadability); GeneralSettings enforceReadability(bool enforceReadability); @@ -125,6 +127,7 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings call({ ThemeMode themeMode, double uiScaleFactor, + bool disableAnimations, bool enableReadability, bool enforceReadability, Set? deleteBrowsingDataOnQuit, @@ -185,6 +188,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings uiScaleFactor(double uiScaleFactor) => call(uiScaleFactor: uiScaleFactor); + @override + GeneralSettings disableAnimations(bool disableAnimations) => + call(disableAnimations: disableAnimations); + @override GeneralSettings enableReadability(bool enableReadability) => call(enableReadability: enableReadability); @@ -381,6 +388,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings call({ Object? themeMode = const $CopyWithPlaceholder(), Object? uiScaleFactor = const $CopyWithPlaceholder(), + Object? disableAnimations = const $CopyWithPlaceholder(), Object? enableReadability = const $CopyWithPlaceholder(), Object? enforceReadability = const $CopyWithPlaceholder(), Object? deleteBrowsingDataOnQuit = const $CopyWithPlaceholder(), @@ -436,6 +444,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.uiScaleFactor // ignore: cast_nullable_to_non_nullable : uiScaleFactor as double, + disableAnimations: + disableAnimations == const $CopyWithPlaceholder() || + disableAnimations == null + ? _value.disableAnimations + // ignore: cast_nullable_to_non_nullable + : disableAnimations as bool, enableReadability: enableReadability == const $CopyWithPlaceholder() || enableReadability == null @@ -711,6 +725,7 @@ GeneralSettings _$GeneralSettingsFromJson( ) => GeneralSettings.withDefaults( themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']), uiScaleFactor: (json['uiScaleFactor'] as num?)?.toDouble(), + disableAnimations: json['disableAnimations'] as bool?, enableReadability: json['enableReadability'] as bool?, enforceReadability: json['enforceReadability'] as bool?, deleteBrowsingDataOnQuit: (json['deleteBrowsingDataOnQuit'] as List?) @@ -799,6 +814,7 @@ Map _$GeneralSettingsToJson( ) => { 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'uiScaleFactor': instance.uiScaleFactor, + 'disableAnimations': instance.disableAnimations, '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 dd3b591b..e4f3f7f3 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -52,6 +52,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.double, db.typeMapping, ), + 'disableAnimations': settings['disableAnimations']?.readAs( + DriftSqlType.bool, + 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 d9b2e649..aa8679c5 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'2098a12825b94a9b39d2e24f90a6afaab47fe3c7'; + r'12e49ad38ff25c6bad1680b3eb3a01274ebfb35f'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/app/lib/main.dart b/app/lib/main.dart index 0c0a570b..6bb37f2e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -133,6 +133,11 @@ class _MainWidget extends HookConsumerWidget { (value) => value.uiScaleFactor, ), ); + final disableAnimations = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.disableAnimations, + ), + ); useOnInitialization(() async { await CountryCodes.init(); @@ -261,6 +266,7 @@ class _MainWidget extends HookConsumerWidget { ), themeMode: themeMode, uiScaleFactor: uiScaleFactor, + disableAnimations: disableAnimations, ); }, ); diff --git a/app/lib/presentation/main_app.dart b/app/lib/presentation/main_app.dart index b44d89c6..aa1f8dd5 100644 --- a/app/lib/presentation/main_app.dart +++ b/app/lib/presentation/main_app.dart @@ -31,12 +31,14 @@ class MainApp extends HookConsumerWidget { final ThemeData? darkTheme; final ThemeMode? themeMode; final double uiScaleFactor; + final bool disableAnimations; const MainApp({ required this.theme, required this.darkTheme, required this.themeMode, required this.uiScaleFactor, + required this.disableAnimations, super.key, }); @@ -54,8 +56,9 @@ class MainApp extends HookConsumerWidget { darkTheme: darkTheme, themeMode: themeMode, builder: (context, child) { - return _AppUiScale( + return _AppMediaQueryOverrides( uiScaleFactor: uiScaleFactor, + disableAnimations: disableAnimations, child: child ?? const SizedBox.shrink(), ); }, @@ -84,8 +87,9 @@ class MainApp extends HookConsumerWidget { themeMode: themeMode, routerConfig: router.value, builder: (context, child) { - return _AppUiScale( + return _AppMediaQueryOverrides( uiScaleFactor: uiScaleFactor, + disableAnimations: disableAnimations, child: _SyncEventListener( child: child ?? const SizedBox.shrink(), ), @@ -100,8 +104,9 @@ class MainApp extends HookConsumerWidget { darkTheme: darkTheme, themeMode: themeMode, builder: (context, child) { - return _AppUiScale( + return _AppMediaQueryOverrides( uiScaleFactor: uiScaleFactor, + disableAnimations: disableAnimations, child: child ?? const SizedBox.shrink(), ); }, @@ -125,29 +130,54 @@ class MainApp extends HookConsumerWidget { } } -class _AppUiScale extends StatelessWidget { +MediaQueryData applyAppMediaQueryOverrides({ + required MediaQueryData mediaQuery, + required double uiScaleFactor, + required bool disableAnimations, +}) { + final textScaler = uiScaleFactor == 1.0 + ? mediaQuery.textScaler + : _AppTextScaler( + baseTextScaler: mediaQuery.textScaler, + uiScaleFactor: uiScaleFactor, + ); + + if (disableAnimations) { + return mediaQuery.copyWith(textScaler: textScaler, disableAnimations: true); + } + + if (uiScaleFactor == 1.0) { + return mediaQuery; + } + + return mediaQuery.copyWith(textScaler: textScaler); +} + +class _AppMediaQueryOverrides extends StatelessWidget { final double uiScaleFactor; + final bool disableAnimations; final Widget child; - const _AppUiScale({required this.uiScaleFactor, required this.child}); + const _AppMediaQueryOverrides({ + required this.uiScaleFactor, + required this.disableAnimations, + required this.child, + }); @override Widget build(BuildContext context) { - if (uiScaleFactor == 1.0) { + if (uiScaleFactor == 1.0 && !disableAnimations) { return child; } final mediaQuery = MediaQuery.of(context); - - return MediaQuery( - data: mediaQuery.copyWith( - textScaler: _AppTextScaler( - baseTextScaler: mediaQuery.textScaler, - uiScaleFactor: uiScaleFactor, - ), - ), - child: child, + final overriddenMediaQuery = applyAppMediaQueryOverrides( + mediaQuery: mediaQuery, + uiScaleFactor: uiScaleFactor, + disableAnimations: disableAnimations, ); + + return MediaQuery(data: overriddenMediaQuery, child: child); } } diff --git a/app/test/presentation/main_app_test.dart b/app/test/presentation/main_app_test.dart new file mode 100644 index 00000000..f79aaeaa --- /dev/null +++ b/app/test/presentation/main_app_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:weblibre/presentation/main_app.dart'; + +void main() { + group('applyAppMediaQueryOverrides', () { + test('returns the original media query when no overrides are enabled', () { + const mediaQuery = MediaQueryData(); + + final result = applyAppMediaQueryOverrides( + mediaQuery: mediaQuery, + uiScaleFactor: 1.0, + disableAnimations: false, + ); + + expect(result, same(mediaQuery)); + }); + + test( + 'preserves the system animation preference when app override is off', + () { + const mediaQuery = MediaQueryData(disableAnimations: true); + + final result = applyAppMediaQueryOverrides( + mediaQuery: mediaQuery, + uiScaleFactor: 1.0, + disableAnimations: false, + ); + + expect(result.disableAnimations, isTrue); + expect(result, same(mediaQuery)); + }, + ); + + test('forces animations off when the app setting is enabled', () { + const mediaQuery = MediaQueryData(disableAnimations: false); + + final result = applyAppMediaQueryOverrides( + mediaQuery: mediaQuery, + uiScaleFactor: 1.0, + disableAnimations: true, + ); + + expect(result.disableAnimations, isTrue); + }); + + test('applies ui scale on top of the base text scaler', () { + const mediaQuery = MediaQueryData(); + + final result = applyAppMediaQueryOverrides( + mediaQuery: mediaQuery, + uiScaleFactor: 1.25, + disableAnimations: false, + ); + + expect(result.textScaler.scale(20), 25); + }); + }); +}