Compare commits

..

No commits in common. "feature/group-filter-dropdown" and "master" have entirely different histories.

4 changed files with 25 additions and 296 deletions

View file

@ -17,25 +17,6 @@ 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<int> feedsInGroup(int groupId) {
final feedIds = <int>{};
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();

View file

@ -100,7 +100,6 @@ class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
var _isDemoUser = false;
AppLifecycleState? _appState;
List<int> _feedFilter = [];
int? _groupFilter;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
@ -155,7 +154,6 @@ class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
_fetchedCount = 0;
_knownPostsSize = -1;
_feedFilter = [];
_groupFilter = null;
});
}
@ -196,20 +194,8 @@ class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
}
var loggedIn = widget.api.loggedIn();
// 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 posts = widget.api.cache.items.getAll().values.where((element) =>
_feedFilter.isEmpty || _feedFilter.contains(element.feedId));
var savedPosts = posts.where((element) => element.isSaved);
var unreadPosts = posts.where((element) => !element.isRead);
@ -373,11 +359,9 @@ class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
),
PopupMenuItem(
value: "filter",
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})"),
child: Text(_feedFilter.isEmpty
? "Filter..."
: "Filter (${_feedFilter.length}/${widget.api.cache.feeds.size})"),
onTap: () {
Future.delayed(
Duration.zero,
@ -387,11 +371,9 @@ class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
builder: (context) => FilterMenu(
api: widget.api,
initData: _feedFilter,
initGroup: _groupFilter,
onConfirm: (items, groupId) {
onConfirm: (items) {
setState(() {
_feedFilter = items;
_groupFilter = groupId;
});
},
),

View file

@ -4,13 +4,11 @@ import 'package:flutter/material.dart';
class FilterMenu extends StatefulWidget {
final FeverAPI api;
final List<int> initData;
final int? initGroup;
final void Function(List<int> items, int? groupId) onConfirm;
final void Function(List<int> items) onConfirm;
const FilterMenu({
super.key,
required this.api,
required this.initData,
required this.initGroup,
required this.onConfirm,
});
@ -21,99 +19,37 @@ class FilterMenu extends StatefulWidget {
class _FilterMenuState extends State<FilterMenu> {
final Map<int, bool> _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<Feed> 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) {
final groups = widget.api.cache.groups.getAll().values.toList()
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
int selected = _checked.values.where((e) => e == true).length;
return SimpleDialog(
title: const Text("Filter"),
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0),
child: DropdownButton<int?>(
value: _groupId,
isExpanded: true,
hint: const Text("All groups"),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text("All groups"),
),
...groups.map((group) => DropdownMenuItem<int?>(
value: group.id,
child: Text(group.title),
)),
],
onChanged: _selectGroup,
child: Text(
selected == 0
? "All feeds will be shown"
: "$selected out of ${_checked.length} feeds will be shown",
),
),
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: _visibleFeeds
children: widget.api.cache.feeds
.getAll()
.values
.map((feed) => Row(
children: [
Checkbox(
@ -124,7 +60,7 @@ class _FilterMenuState extends State<FilterMenu> {
});
},
),
Expanded(child: Text(feed.title)),
Text(feed.title),
],
))
.toList(),
@ -134,21 +70,22 @@ class _FilterMenuState extends State<FilterMenu> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () {
setState(() {
for (var feed in _visibleFeeds) {
_checked[feed.id] = false;
}
onPressed: () {
setState(() {
_checked.forEach((key, value) {
_checked[key] = false;
});
},
child: const Text("Reset")),
});
},
child: const Text("Reset")
),
TextButton(
onPressed: () {
List<int> items = _checked.keys
.where((element) => _checked[element]!)
.toList();
widget.onConfirm(items, _groupId);
widget.onConfirm(items);
Navigator.of(context).pop();
},
child: const Text("Done"),

View file

@ -1,171 +0,0 @@
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<int>? 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<int>? 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<int?>));
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<Checkbox>(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<int>? 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);
});
});
}