@@ -28,6 +28,9 @@ class SavedFiltersBar extends StatefulWidget {
2828
2929class _SavedFiltersBarState extends State <SavedFiltersBar > {
3030 final _scrollController = ScrollController ();
31+ // A map to hold GlobalKeys for each filter chip. This allows us to find
32+ // the chip's context and scroll to it programmatically.
33+ final Map <String , GlobalKey > _chipKeys = {};
3134
3235 @override
3336 void initState () {
@@ -46,171 +49,225 @@ class _SavedFiltersBarState extends State<SavedFiltersBar> {
4649 final l10n = AppLocalizationsX (context).l10n;
4750 final theme = Theme .of (context);
4851
49- return SizedBox (
50- height: 52 ,
51- child: BlocBuilder <HeadlinesFeedBloc , HeadlinesFeedState >(
52- builder: (context, state) {
53- final savedFilters = state.savedFilters;
54- final userPreferences = context
55- .watch <AppBloc >()
56- .state
57- .userContentPreferences;
58-
59- // Determine if the user is following any content to decide whether
60- // to show the "Followed" filter chip.
61- final isFollowingItems =
62- (userPreferences? .followedTopics.isNotEmpty ?? false ) ||
63- (userPreferences? .followedSources.isNotEmpty ?? false ) ||
64- (userPreferences? .followedCountries.isNotEmpty ?? false );
65-
66- final activeFilterId = state.activeFilterId;
67- final listView = ListView (
68- controller: _scrollController,
69- scrollDirection: Axis .horizontal,
70- padding: EdgeInsets .zero,
71- children: [
72- IconButton (
73- icon: const Icon (Icons .filter_list),
74- tooltip: l10n.savedFiltersBarOpenTooltip,
75- onPressed: () => context.goNamed (Routes .feedFilterName),
76- ),
77- const VerticalDivider (
78- width: AppSpacing .md,
79- indent: AppSpacing .md,
80- endIndent: AppSpacing .md,
81- ),
82- Padding (
83- padding: const EdgeInsets .symmetric (horizontal: AppSpacing .xs),
84- child: ChoiceChip (
85- label: Text (l10n.savedFiltersBarAllLabel),
86- labelPadding: const EdgeInsets .symmetric (
87- horizontal: AppSpacing .xs,
88- ),
89- selected: activeFilterId == _allFilterId,
90- showCheckmark: false ,
91- onSelected: (_) {
92- context.read <HeadlinesFeedBloc >().add (
93- AllFilterSelected (
94- adThemeStyle: AdThemeStyle .fromTheme (theme),
95- ),
96- );
97- },
52+ // The BlocListener is responsible for reacting to state changes and
53+ // triggering side effects, in this case, scrolling the active chip
54+ // into view.
55+ return BlocListener <HeadlinesFeedBloc , HeadlinesFeedState >(
56+ // Optimize the listener to only fire when the active filter ID changes.
57+ listenWhen: (previous, current) =>
58+ previous.activeFilterId != current.activeFilterId,
59+ listener: (context, state) {
60+ // We use a post-frame callback to ensure that the widget tree has been
61+ // rebuilt and the new active chip (especially the "Custom" one) is
62+ // laid out on the screen before we try to scroll to it.
63+ WidgetsBinding .instance.addPostFrameCallback ((_) {
64+ final key = _chipKeys[state.activeFilterId];
65+ if (key? .currentContext != null ) {
66+ Scrollable .ensureVisible (
67+ key! .currentContext! ,
68+ duration: const Duration (milliseconds: 350 ),
69+ curve: Curves .easeInOut,
70+ );
71+ }
72+ });
73+ },
74+ child: SizedBox (
75+ height: 52 ,
76+ child: BlocBuilder <HeadlinesFeedBloc , HeadlinesFeedState >(
77+ builder: (context, state) {
78+ // Clear the keys on each build to prevent the map from growing
79+ // indefinitely with old, stale keys.
80+ _chipKeys.clear ();
81+
82+ final savedFilters = state.savedFilters;
83+ final userPreferences = context
84+ .watch <AppBloc >()
85+ .state
86+ .userContentPreferences;
87+
88+ // Determine if the user is following any content to decide whether
89+ // to show the "Followed" filter chip.
90+ final isFollowingItems =
91+ (userPreferences? .followedTopics.isNotEmpty ?? false ) ||
92+ (userPreferences? .followedSources.isNotEmpty ?? false ) ||
93+ (userPreferences? .followedCountries.isNotEmpty ?? false );
94+
95+ final activeFilterId = state.activeFilterId;
96+
97+ // Lazily create and store a GlobalKey for each chip.
98+ // The key is associated with the Padding widget to ensure the
99+ // entire chip area is scrolled into view.
100+ final allKey = _chipKeys.putIfAbsent (
101+ _allFilterId,
102+ () => GlobalKey (),
103+ );
104+ final followedKey = _chipKeys.putIfAbsent (
105+ _followedFilterId,
106+ () => GlobalKey (),
107+ );
108+ final customKey = _chipKeys.putIfAbsent (
109+ _customFilterId,
110+ () => GlobalKey (),
111+ );
112+
113+ final listView = ListView (
114+ controller: _scrollController,
115+ scrollDirection: Axis .horizontal,
116+ padding: EdgeInsets .zero,
117+ children: [
118+ IconButton (
119+ icon: const Icon (Icons .filter_list),
120+ tooltip: l10n.savedFiltersBarOpenTooltip,
121+ onPressed: () => context.goNamed (Routes .feedFilterName),
122+ ),
123+ const VerticalDivider (
124+ width: AppSpacing .md,
125+ indent: AppSpacing .md,
126+ endIndent: AppSpacing .md,
98127 ),
99- ),
100- // Conditionally display the "Followed" filter chip.
101- if (isFollowingItems)
102128 Padding (
129+ key: allKey,
103130 padding: const EdgeInsets .symmetric (
104131 horizontal: AppSpacing .xs,
105132 ),
106133 child: ChoiceChip (
107- label: Text (l10n.savedFiltersBarFollowedLabel ),
134+ label: Text (l10n.savedFiltersBarAllLabel ),
108135 labelPadding: const EdgeInsets .symmetric (
109136 horizontal: AppSpacing .xs,
110137 ),
111- selected: activeFilterId == _followedFilterId ,
138+ selected: activeFilterId == _allFilterId ,
112139 showCheckmark: false ,
113140 onSelected: (_) {
114141 context.read <HeadlinesFeedBloc >().add (
115- FollowedFilterSelected (
142+ AllFilterSelected (
116143 adThemeStyle: AdThemeStyle .fromTheme (theme),
117144 ),
118145 );
119146 },
120147 ),
121148 ),
122- ...savedFilters.map (
123- (filter) => Padding (
124- padding: const EdgeInsets .symmetric (
125- horizontal: AppSpacing .xs,
126- ),
127- child: ChoiceChip (
128- label: Text (filter.name),
129- labelPadding: const EdgeInsets .symmetric (
149+ // Conditionally display the "Followed" filter chip.
150+ if (isFollowingItems)
151+ Padding (
152+ key: followedKey,
153+ padding: const EdgeInsets .symmetric (
130154 horizontal: AppSpacing .xs,
131155 ),
132- selected: activeFilterId == filter.id,
133- showCheckmark: false ,
134- onSelected: (_) {
135- context.read <HeadlinesFeedBloc >().add (
136- SavedFilterSelected (
137- filter: filter,
138- adThemeStyle: AdThemeStyle .fromTheme (theme),
139- ),
140- );
141- },
142- ),
143- ),
144- ),
145- if (activeFilterId == _customFilterId)
146- Padding (
147- padding: const EdgeInsets .symmetric (
148- horizontal: AppSpacing .xs,
156+ child: ChoiceChip (
157+ label: Text (l10n.savedFiltersBarFollowedLabel),
158+ labelPadding: const EdgeInsets .symmetric (
159+ horizontal: AppSpacing .xs,
160+ ),
161+ selected: activeFilterId == _followedFilterId,
162+ showCheckmark: false ,
163+ onSelected: (_) {
164+ context.read <HeadlinesFeedBloc >().add (
165+ FollowedFilterSelected (
166+ adThemeStyle: AdThemeStyle .fromTheme (theme),
167+ ),
168+ );
169+ },
170+ ),
149171 ),
150- child: ChoiceChip (
151- label: Text (l10n.savedFiltersBarCustomLabel),
152- labelPadding: const EdgeInsets .symmetric (
172+ ...savedFilters.map ((filter) {
173+ final filterKey = _chipKeys.putIfAbsent (
174+ filter.id,
175+ () => GlobalKey (),
176+ );
177+ return Padding (
178+ key: filterKey,
179+ padding: const EdgeInsets .symmetric (
153180 horizontal: AppSpacing .xs,
154181 ),
155- showCheckmark: false ,
156- selected: true ,
157- onSelected: null ,
158- selectedColor: theme.colorScheme.primary.withOpacity (0.2 ),
159- labelStyle: TextStyle (
160- color: theme.colorScheme.onSurface.withOpacity (0.7 ),
182+ child: ChoiceChip (
183+ label: Text (filter.name),
184+ labelPadding: const EdgeInsets .symmetric (
185+ horizontal: AppSpacing .xs,
186+ ),
187+ selected: activeFilterId == filter.id,
188+ showCheckmark: false ,
189+ onSelected: (_) {
190+ context.read <HeadlinesFeedBloc >().add (
191+ SavedFilterSelected (
192+ filter: filter,
193+ adThemeStyle: AdThemeStyle .fromTheme (theme),
194+ ),
195+ );
196+ },
197+ ),
198+ );
199+ }),
200+ if (activeFilterId == _customFilterId)
201+ Padding (
202+ key: customKey,
203+ padding: const EdgeInsets .symmetric (
204+ horizontal: AppSpacing .xs,
205+ ),
206+ child: ChoiceChip (
207+ label: Text (l10n.savedFiltersBarCustomLabel),
208+ labelPadding: const EdgeInsets .symmetric (
209+ horizontal: AppSpacing .xs,
210+ ),
211+ showCheckmark: false ,
212+ selected: true ,
213+ onSelected: null ,
214+ selectedColor: theme.colorScheme.primary.withOpacity (0.2 ),
215+ labelStyle: TextStyle (
216+ color: theme.colorScheme.onSurface.withOpacity (0.7 ),
217+ ),
161218 ),
162219 ),
163- ) ,
164- ],
165- );
166-
167- // Determine if the fade should be shown based on scroll position.
168- var showStartFade = false ;
169- var showEndFade = false ;
170- if ( _scrollController.hasClients &&
171- _scrollController.position.maxScrollExtent > 0 ) {
172- final pixels = _scrollController.position.pixels ;
173- final minScroll = _scrollController.position.minScrollExtent ;
174- final maxScroll = _scrollController.position.maxScrollExtent;
175-
176- // Show start fade if not at the beginning.
177- if (pixels > minScroll) {
178- showStartFade = true ;
179- }
180- // Show end fade if not at the end.
181- if (pixels < maxScroll) {
182- showEndFade = true ;
220+ ] ,
221+ );
222+
223+ // Determine if the fade should be shown based on scroll position.
224+ var showStartFade = false ;
225+ var showEndFade = false ;
226+ if (_scrollController.hasClients &&
227+ _scrollController.position.maxScrollExtent > 0 ) {
228+ final pixels = _scrollController.position.pixels;
229+ final minScroll = _scrollController.position.minScrollExtent ;
230+ final maxScroll = _scrollController.position.maxScrollExtent ;
231+
232+ // Show start fade if not at the beginning.
233+ if (pixels > minScroll) {
234+ showStartFade = true ;
235+ }
236+ // Show end fade if not at the end.
237+ if (pixels < maxScroll) {
238+ showEndFade = true ;
239+ }
183240 }
184- }
185241
186- // Define the gradient colors and stops based on fade visibility.
187- final colors = < Color > [
188- if (showStartFade) Colors .transparent,
189- Colors .black,
190- Colors .black,
191- if (showEndFade) Colors .transparent,
192- ];
193-
194- final stops = < double > [
195- if (showStartFade) 0.0 ,
196- if (showStartFade) 0.05 else 0.0 ,
197- if (showEndFade) 0.95 else 1.0 ,
198- if (showEndFade) 1.0 ,
199- ];
200-
201- return ShaderMask (
202- shaderCallback: (bounds) {
203- return LinearGradient (
204- begin: Alignment .centerLeft,
205- end: Alignment .centerRight,
206- colors: colors,
207- stops: stops,
208- ).createShader (bounds);
209- },
210- blendMode: BlendMode .dstIn,
211- child: listView,
212- );
213- },
242+ // Define the gradient colors and stops based on fade visibility.
243+ final colors = < Color > [
244+ if (showStartFade) Colors .transparent,
245+ Colors .black,
246+ Colors .black,
247+ if (showEndFade) Colors .transparent,
248+ ];
249+
250+ final stops = < double > [
251+ if (showStartFade) 0.0 ,
252+ if (showStartFade) 0.05 else 0.0 ,
253+ if (showEndFade) 0.95 else 1.0 ,
254+ if (showEndFade) 1.0 ,
255+ ];
256+
257+ return ShaderMask (
258+ shaderCallback: (bounds) {
259+ return LinearGradient (
260+ begin: Alignment .centerLeft,
261+ end: Alignment .centerRight,
262+ colors: colors,
263+ stops: stops,
264+ ).createShader (bounds);
265+ },
266+ blendMode: BlendMode .dstIn,
267+ child: listView,
268+ );
269+ },
270+ ),
214271 ),
215272 );
216273 }
0 commit comments