From afa1127125b6dfc36d80956d7e2db05d6456f1be Mon Sep 17 00:00:00 2001 From: Open Source Contributor Date: Tue, 27 Oct 2026 14:43:50 +0000 Subject: [PATCH] Add group dropdown to post filter dialog Users can now restrict the Feed/Unread/Saved post lists to a specific Fever group from the filter dialog. The app already fetched and cached groups and feeds_groups, but nothing in the UI ever exposed them, so posts could only be filtered by individually selected feeds. - Add APICache.feedsInGroup() to resolve a group to the feeds that belong to it, skipping feed ids that no longer exist in the cache - Add an "All groups"/group dropdown to FilterMenu; choosing a group limits the feed checklist to that group's feeds, and the dialog now reports the selected group together with the selected feeds - Wire a new _groupFilter into the post filtering (ANDed with the feed filter), surface the active group in the app-bar entry, and reset it on a full refresh - Cover the new behavior with unit tests for feedsInGroup() and widget tests for the group dropdown Closes #16 Co-Authored-By: Claude Fable 5 --- lib/api/cache.dart | 19 ++++ lib/main.dart | 30 ++++-- lib/widgets/filter_menu.dart | 101 +++++++++++++++++---- test/group_filter_test.dart | 171 +++++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 25 deletions(-) create mode 100644 test/group_filter_test.dart diff --git a/lib/api/cache.dart b/lib/api/cache.dart index 1298652..2299020 100644 --- a/lib/api/cache.dart +++ b/lib/api/cache.dart @@ -17,6 +17,25 @@ class APICache { favicons = ObjectCache(api); } + /// Returns the ids of all feeds that belong to the given group. + /// + /// Falls back to an empty list if the group exists but no feeds have been + /// fetched yet. Feeds that do not exist in the cache (deleted or not fetched + /// yet) are skipped, so the result never contains stale feed ids. + List feedsInGroup(int groupId) { + final feedIds = {}; + + for (final feedsGroup in feedsGroups) { + if (feedsGroup.groupId != groupId) continue; + + for (final feedId in feedsGroup.feedIds) { + if (feeds.get(feedId) != null) feedIds.add(feedId); + } + } + + return feedIds.toList(); + } + void clear() { items._items.clear(); feeds._items.clear(); diff --git a/lib/main.dart b/lib/main.dart index 2f0d478..a142403 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -100,6 +100,7 @@ class _MyHomePageState extends State with WidgetsBindingObserver { var _isDemoUser = false; AppLifecycleState? _appState; List _feedFilter = []; + int? _groupFilter; @override void didChangeAppLifecycleState(AppLifecycleState state) { @@ -154,6 +155,7 @@ class _MyHomePageState extends State with WidgetsBindingObserver { _fetchedCount = 0; _knownPostsSize = -1; _feedFilter = []; + _groupFilter = null; }); } @@ -194,8 +196,20 @@ class _MyHomePageState extends State with WidgetsBindingObserver { } var loggedIn = widget.api.loggedIn(); - var posts = widget.api.cache.items.getAll().values.where((element) => - _feedFilter.isEmpty || _feedFilter.contains(element.feedId)); + + // Resolve the selected group to its feeds once, so all three pages share + // the same filtering. + var groupFeedIds = _groupFilter == null + ? null + : widget.api.cache.feedsInGroup(_groupFilter!); + + var posts = widget.api.cache.items.getAll().values.where((element) { + if (groupFeedIds != null && + !groupFeedIds.contains(element.feedId)) { + return false; + } + return _feedFilter.isEmpty || _feedFilter.contains(element.feedId); + }); var savedPosts = posts.where((element) => element.isSaved); var unreadPosts = posts.where((element) => !element.isRead); @@ -359,9 +373,11 @@ class _MyHomePageState extends State with WidgetsBindingObserver { ), PopupMenuItem( value: "filter", - child: Text(_feedFilter.isEmpty - ? "Filter..." - : "Filter (${_feedFilter.length}/${widget.api.cache.feeds.size})"), + child: Text(_groupFilter != null + ? "Filter (group: ${widget.api.cache.groups.get(_groupFilter!)?.title ?? _groupFilter})" + : _feedFilter.isEmpty + ? "Filter..." + : "Filter (${_feedFilter.length}/${widget.api.cache.feeds.size})"), onTap: () { Future.delayed( Duration.zero, @@ -371,9 +387,11 @@ class _MyHomePageState extends State with WidgetsBindingObserver { builder: (context) => FilterMenu( api: widget.api, initData: _feedFilter, - onConfirm: (items) { + initGroup: _groupFilter, + onConfirm: (items, groupId) { setState(() { _feedFilter = items; + _groupFilter = groupId; }); }, ), diff --git a/lib/widgets/filter_menu.dart b/lib/widgets/filter_menu.dart index 98e1e5c..f780745 100644 --- a/lib/widgets/filter_menu.dart +++ b/lib/widgets/filter_menu.dart @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; class FilterMenu extends StatefulWidget { final FeverAPI api; final List initData; - final void Function(List items) onConfirm; + final int? initGroup; + final void Function(List items, int? groupId) onConfirm; const FilterMenu({ super.key, required this.api, required this.initData, + required this.initGroup, required this.onConfirm, }); @@ -19,37 +21,99 @@ class FilterMenu extends StatefulWidget { class _FilterMenuState extends State { final Map _checked = {}; + /// null means "All groups", otherwise the id of the selected Fever group. + int? _groupId; + @override void initState() { super.initState(); + _groupId = widget.initGroup; for (var feed in widget.api.cache.feeds.getAll().values) { _checked[feed.id] = widget.initData.contains(feed.id); } } + /// Only the feeds of the selected group are listed, or all feeds when no + /// group is selected. + List get _visibleFeeds { + final allFeeds = widget.api.cache.feeds.getAll().values; + if (_groupId == null) return allFeeds.toList(); + + final feedIds = widget.api.cache.feedsInGroup(_groupId!); + return allFeeds.where((feed) => feedIds.contains(feed.id)).toList(); + } + + void _selectGroup(int? groupId) { + setState(() { + _groupId = groupId; + + if (groupId == null) { + // "All groups" shows every feed again, so there is nothing to + // explicitly filter for. + _checked.updateAll((key, value) => false); + return; + } + + // Selecting a group restricts the checked feeds to the ones inside it. + final feedIds = widget.api.cache.feedsInGroup(groupId); + for (var feed in widget.api.cache.feeds.getAll().values) { + _checked[feed.id] = feedIds.contains(feed.id); + } + }); + } + + String get _summary { + final feeds = _visibleFeeds; + final selected = feeds.where((feed) => _checked[feed.id] == true).length; + + if (_groupId == null) { + return selected == 0 + ? "All feeds will be shown" + : "$selected out of ${_checked.length} feeds will be shown"; + } + + final groupTitle = + widget.api.cache.groups.get(_groupId!)?.title ?? "selected group"; + return '"$groupTitle": $selected out of ${feeds.length} feeds will be shown'; + } + @override Widget build(BuildContext context) { - int selected = _checked.values.where((e) => e == true).length; + final groups = widget.api.cache.groups.getAll().values.toList() + ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); return SimpleDialog( title: const Text("Filter"), children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0), - child: Text( - selected == 0 - ? "All feeds will be shown" - : "$selected out of ${_checked.length} feeds will be shown", + child: DropdownButton( + value: _groupId, + isExpanded: true, + hint: const Text("All groups"), + items: [ + const DropdownMenuItem( + value: null, + child: Text("All groups"), + ), + ...groups.map((group) => DropdownMenuItem( + value: group.id, + child: Text(group.title), + )), + ], + onChanged: _selectGroup, ), ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0), + child: Text(_summary), + ), Container( width: 300, height: 280, padding: const EdgeInsets.all(8.0), child: ListView( - children: widget.api.cache.feeds - .getAll() - .values + children: _visibleFeeds .map((feed) => Row( children: [ Checkbox( @@ -60,7 +124,7 @@ class _FilterMenuState extends State { }); }, ), - Text(feed.title), + Expanded(child: Text(feed.title)), ], )) .toList(), @@ -70,22 +134,21 @@ class _FilterMenuState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ TextButton( - onPressed: () { - setState(() { - _checked.forEach((key, value) { - _checked[key] = false; + onPressed: () { + setState(() { + for (var feed in _visibleFeeds) { + _checked[feed.id] = false; + } }); - }); - }, - child: const Text("Reset") - ), + }, + child: const Text("Reset")), TextButton( onPressed: () { List items = _checked.keys .where((element) => _checked[element]!) .toList(); - widget.onConfirm(items); + widget.onConfirm(items, _groupId); Navigator.of(context).pop(); }, child: const Text("Done"), diff --git a/test/group_filter_test.dart b/test/group_filter_test.dart new file mode 100644 index 0000000..d18be63 --- /dev/null +++ b/test/group_filter_test.dart @@ -0,0 +1,171 @@ +import 'package:feet/api/api.dart'; +import 'package:feet/widgets/filter_menu.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Builds an API with two groups and three feeds: +/// - group 1 "News" -> feeds 10 (Verge), 11 (Ars) +/// - group 2 "Blogs" -> feed 12 (Daring) +/// Group 3 references a feed that is not in the cache to test stale-id +/// handling. +FeverAPI _seedApi() { + final api = FeverAPI(apiUrl: "https://example.org"); + + api.cache.groups.set(1, Group(api, id: 1, title: "News")); + api.cache.groups.set(2, Group(api, id: 2, title: "Blogs")); + api.cache.groups.set(3, Group(api, id: 3, title: "Stale")); + + Feed feed(int id, String title) => Feed(api, + id: id, + faviconId: id, + title: title, + url: "", + siteUrl: "", + isSpark: false, + lastUpdatedOnTime: DateTime.fromMillisecondsSinceEpoch(0)); + + api.cache.feeds.set(10, feed(10, "Verge")); + api.cache.feeds.set(11, feed(11, "Ars")); + api.cache.feeds.set(12, feed(12, "Daring")); + + api.cache.feedsGroups.addAll([ + FeedsGroup(api, groupId: 1, feedIds: const [10, 11]), + FeedsGroup(api, groupId: 2, feedIds: const [12]), + FeedsGroup(api, groupId: 3, feedIds: const [12, 99]), + ]); + + return api; +} + +void main() { + group("APICache.feedsInGroup", () { + test("resolves a group to its cached feeds", () { + final api = _seedApi(); + + expect(api.cache.feedsInGroup(1), [10, 11]); + expect(api.cache.feedsInGroup(2), [12]); + }); + + test("returns an empty list for an unknown group", () { + final api = _seedApi(); + + expect(api.cache.feedsInGroup(42), isEmpty); + }); + + test("skips feed ids that are no longer in the cache", () { + final api = _seedApi(); + + // Group 3 references feed 99 which does not exist in the cache. + expect(api.cache.feedsInGroup(3), [12]); + }); + }); + + group("FilterMenu group dropdown", () { + testWidgets("lists all feeds and applies the all-groups default", + (tester) async { + List? receivedFeeds; + int? receivedGroup = -1; + final api = _seedApi(); + + await tester.pumpWidget(MaterialApp( + home: FilterMenu( + api: api, + initData: const [], + initGroup: null, + onConfirm: (feeds, groupId) { + receivedFeeds = feeds; + receivedGroup = groupId; + }, + ), + )); + await tester.pumpAndSettle(); + + expect(find.text("Verge"), findsOneWidget); + expect(find.text("Ars"), findsOneWidget); + expect(find.text("Daring"), findsOneWidget); + expect(find.text("All feeds will be shown"), findsOneWidget); + + await tester.tap(find.text("Done")); + await tester.pumpAndSettle(); + + expect(receivedFeeds, isEmpty); + expect(receivedGroup, isNull); + }); + + testWidgets( + "selecting a group restricts the checklist to its feeds and applies it", + (tester) async { + List? receivedFeeds; + int? receivedGroup = -1; + final api = _seedApi(); + + await tester.pumpWidget(MaterialApp( + home: FilterMenu( + api: api, + initData: const [], + initGroup: null, + onConfirm: (feeds, groupId) { + receivedFeeds = feeds; + receivedGroup = groupId; + }, + ), + )); + await tester.pumpAndSettle(); + + // Open the group dropdown and choose "News". + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + // Material keeps the selected item in the button as well as the open + // menu; the menu route is added last in the tree. + await tester.tap(find.text("News").last); + await tester.pumpAndSettle(); + + // Only feeds of the News group are listed... + expect(find.text("Verge"), findsOneWidget); + expect(find.text("Ars"), findsOneWidget); + expect(find.text("Daring"), findsNothing); + expect(find.textContaining('2 out of 2 feeds'), findsOneWidget); + + // ...and they are pre-selected. + final checkboxes = + tester.widgetList(find.byType(Checkbox)).toList(); + expect(checkboxes.every((c) => c.value == true), isTrue); + + await tester.tap(find.text("Done")); + await tester.pumpAndSettle(); + + expect(receivedFeeds, [10, 11]); + expect(receivedGroup, 1); + }); + + testWidgets("opens with a group preselected", (tester) async { + List? receivedFeeds; + int? receivedGroup = -1; + final api = _seedApi(); + + await tester.pumpWidget(MaterialApp( + home: FilterMenu( + api: api, + initData: const [12], + initGroup: 2, + onConfirm: (feeds, groupId) { + receivedFeeds = feeds; + receivedGroup = groupId; + }, + ), + )); + await tester.pumpAndSettle(); + + expect(find.text("Daring"), findsOneWidget); + expect(find.text("Verge"), findsNothing); + expect( + find.text('"Blogs": 1 out of 1 feeds will be shown'), findsOneWidget); + + await tester.tap(find.text("Done")); + await tester.pumpAndSettle(); + + expect(receivedFeeds, [12]); + expect(receivedGroup, 2); + }); + }); +} -- 2.43.7