diff --git a/apps/weblibre/lib/features/settings/domain/providers/pending_settings_highlight.dart b/apps/weblibre/lib/features/settings/domain/providers/pending_settings_highlight.dart new file mode 100644 index 00000000..a83174ec --- /dev/null +++ b/apps/weblibre/lib/features/settings/domain/providers/pending_settings_highlight.dart @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'pending_settings_highlight.g.dart'; + +/// Holds the [SettingsEntryDefinition.title] that a destination settings +/// screen should auto-scroll to and briefly pulse after navigation. Set by +/// the global settings search before pushing the destination route; consumed +/// (and cleared) by the matching entry once it has been highlighted. +@Riverpod(keepAlive: true) +class PendingSettingsHighlight extends _$PendingSettingsHighlight { + @override + String? build() => null; + + // ignore: use_setters_to_change_properties + void set(String? title) { + state = title; + } + + void clear() { + state = null; + } +} diff --git a/apps/weblibre/lib/features/settings/domain/providers/pending_settings_highlight.g.dart b/apps/weblibre/lib/features/settings/domain/providers/pending_settings_highlight.g.dart new file mode 100644 index 00000000..549a3462 --- /dev/null +++ b/apps/weblibre/lib/features/settings/domain/providers/pending_settings_highlight.g.dart @@ -0,0 +1,80 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pending_settings_highlight.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Holds the [SettingsEntryDefinition.title] that a destination settings +/// screen should auto-scroll to and briefly pulse after navigation. Set by +/// the global settings search before pushing the destination route; consumed +/// (and cleared) by the matching entry once it has been highlighted. + +@ProviderFor(PendingSettingsHighlight) +final pendingSettingsHighlightProvider = PendingSettingsHighlightProvider._(); + +/// Holds the [SettingsEntryDefinition.title] that a destination settings +/// screen should auto-scroll to and briefly pulse after navigation. Set by +/// the global settings search before pushing the destination route; consumed +/// (and cleared) by the matching entry once it has been highlighted. +final class PendingSettingsHighlightProvider + extends $NotifierProvider { + /// Holds the [SettingsEntryDefinition.title] that a destination settings + /// screen should auto-scroll to and briefly pulse after navigation. Set by + /// the global settings search before pushing the destination route; consumed + /// (and cleared) by the matching entry once it has been highlighted. + PendingSettingsHighlightProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pendingSettingsHighlightProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pendingSettingsHighlightHash(); + + @$internal + @override + PendingSettingsHighlight create() => PendingSettingsHighlight(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pendingSettingsHighlightHash() => + r'e64c23c22991e94ec6115ac27b5d7b9d33b801be'; + +/// Holds the [SettingsEntryDefinition.title] that a destination settings +/// screen should auto-scroll to and briefly pulse after navigation. Set by +/// the global settings search before pushing the destination route; consumed +/// (and cleared) by the matching entry once it has been highlighted. + +abstract class _$PendingSettingsHighlight extends $Notifier { + String? build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + String?, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart index 80d9fa8e..d5e39259 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart @@ -21,7 +21,9 @@ 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'; +import 'package:weblibre/features/settings/domain/providers/pending_settings_highlight.dart'; import 'package:weblibre/features/settings/presentation/screens/advanced_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/browsing_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/extensions_settings.dart'; @@ -327,7 +329,7 @@ class _CategoryTile extends StatelessWidget { } } -class _SearchResultTile extends StatelessWidget { +class _SearchResultTile extends HookConsumerWidget { final String title; final String? subtitle; final String category; @@ -345,7 +347,7 @@ class _SearchResultTile extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return ListTile( leading: Icon(icon), title: Text(title), @@ -361,7 +363,16 @@ class _SearchResultTile extends StatelessWidget { ), trailing: const Icon(Icons.chevron_right), onTap: () async { - await onTap(context); + final pendingHighlight = ref.read( + pendingSettingsHighlightProvider.notifier, + ); + pendingHighlight.set(title); + try { + await onTap(context); + } catch (_) { + pendingHighlight.clear(); + rethrow; + } }, ); } diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart b/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart index efe02a12..97340abf 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart @@ -20,6 +20,12 @@ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/settings/domain/providers/pending_settings_highlight.dart'; + +/// Default total-entry count at or below which [SettingsDetailScaffold] hides +/// its search field — searching three toggles is just visual noise. +const int kDefaultSettingsSearchEntryThreshold = 10; class SettingsEntryDefinition { final String title; @@ -82,6 +88,7 @@ class SettingsDetailScaffold extends HookWidget { final List sections; final List actions; final String searchHintText; + final int searchEntryThreshold; const SettingsDetailScaffold({ super.key, @@ -91,16 +98,22 @@ class SettingsDetailScaffold extends HookWidget { required this.sections, this.actions = const [], this.searchHintText = 'Search settings', + this.searchEntryThreshold = kDefaultSettingsSearchEntryThreshold, }); @override Widget build(BuildContext context) { + final totalEntries = sections.fold( + 0, + (sum, section) => sum + section.entries.length, + ); + final showSearch = totalEntries > searchEntryThreshold; + final search = useSettingsSearch(); - final filteredSections = filterSettingsSections( - sections: sections, - query: search.rawQuery, - ); + final filteredSections = showSearch + ? filterSettingsSections(sections: sections, query: search.rawQuery) + : sections; return Scaffold( body: SafeArea( @@ -115,21 +128,27 @@ class SettingsDetailScaffold extends HookWidget { title: Text(title), actions: actions, ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - sliver: SliverToBoxAdapter( - child: SettingsSearchField( - controller: search.controller, - hintText: searchHintText, + if (showSearch) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: SettingsSearchField( + controller: search.controller, + hintText: searchHintText, + ), ), ), - ), SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + padding: EdgeInsets.fromLTRB( + 16, + showSearch ? 24 : 16, + 16, + 20, + ), sliver: SliverToBoxAdapter( child: SettingsSectionList( sections: filteredSections, - query: search.rawQuery, + query: showSearch ? search.rawQuery : '', ), ), ), @@ -297,7 +316,14 @@ List buildSettingsSectionWidgets( entryIndex++ ) ...[ if (entryIndex > 0) const Divider(height: 1), - sections[sectionIndex].entries[entryIndex].child, + _HighlightableEntry( + key: ValueKey( + '${sections[sectionIndex].title}/' + '${sections[sectionIndex].entries[entryIndex].title}', + ), + title: sections[sectionIndex].entries[entryIndex].title, + child: sections[sectionIndex].entries[entryIndex].child, + ), ], ], ), @@ -306,6 +332,87 @@ List buildSettingsSectionWidgets( ]; } +/// Wraps a settings entry tile and, when [PendingSettingsHighlight] matches +/// [title], auto-scrolls it into view and briefly pulses a tint behind it. +/// Self-clears the pending highlight as soon as the destination entry handles +/// it so the effect doesn't replay on rebuild or back-navigation. +class _HighlightableEntry extends HookConsumerWidget { + final String title; + final Widget child; + + const _HighlightableEntry({ + super.key, + required this.title, + required this.child, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pending = ref.watch(pendingSettingsHighlightProvider); + final isTarget = pending != null && pending == title; + + final controller = useAnimationController( + duration: const Duration(milliseconds: 1500), + ); + + final animation = useMemoized( + () => TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeOut)), + weight: 20, + ), + TweenSequenceItem(tween: ConstantTween(1.0), weight: 30), + TweenSequenceItem( + tween: Tween( + begin: 1.0, + end: 0.0, + ).chain(CurveTween(curve: Curves.easeIn)), + weight: 50, + ), + ]).animate(controller), + [controller], + ); + + useEffect(() { + if (!isTarget) return null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + // Clear immediately once this entry claims the highlight so stale + // state cannot survive a quick pop or interrupted animation. + ref.read(pendingSettingsHighlightProvider.notifier).clear(); + if (!context.mounted) return; + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + alignment: 0.2, + ); + controller.forward(from: 0); + }); + + return null; + }, [isTarget]); + + final highlightColor = Theme.of(context).colorScheme.secondaryContainer; + + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + final t = animation.value; + if (t == 0.0) return child!; + return ColoredBox( + color: highlightColor.withValues(alpha: t * 0.7), + child: child, + ); + }, + child: child, + ); + } +} + List filterSettingsSections({ required List sections, required String query,