Compare commits
1 commit
master
...
feature/gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa1127125 |
|
|
@ -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<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();
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
|
|||
var _isDemoUser = false;
|
||||
AppLifecycleState? _appState;
|
||||
List<int> _feedFilter = [];
|
||||
int? _groupFilter;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
|
|
@ -154,6 +155,7 @@ class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
|
|||
_fetchedCount = 0;
|
||||
_knownPostsSize = -1;
|
||||
_feedFilter = [];
|
||||
_groupFilter = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -194,8 +196,20 @@ class _MyHomePageState extends State<MyHomePage> 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<MyHomePage> 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<MyHomePage> with WidgetsBindingObserver {
|
|||
builder: (context) => FilterMenu(
|
||||
api: widget.api,
|
||||
initData: _feedFilter,
|
||||
onConfirm: (items) {
|
||||
initGroup: _groupFilter,
|
||||
onConfirm: (items, groupId) {
|
||||
setState(() {
|
||||
_feedFilter = items;
|
||||
_groupFilter = groupId;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
|
|||
class FilterMenu extends StatefulWidget {
|
||||
final FeverAPI api;
|
||||
final List<int> initData;
|
||||
final void Function(List<int> items) onConfirm;
|
||||
final int? initGroup;
|
||||
final void Function(List<int> 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<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) {
|
||||
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<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,
|
||||
),
|
||||
),
|
||||
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<FilterMenu> {
|
|||
});
|
||||
},
|
||||
),
|
||||
Text(feed.title),
|
||||
Expanded(child: Text(feed.title)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
|
|
@ -70,22 +134,21 @@ class _FilterMenuState extends State<FilterMenu> {
|
|||
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<int> items = _checked.keys
|
||||
.where((element) => _checked[element]!)
|
||||
.toList();
|
||||
|
||||
widget.onConfirm(items);
|
||||
widget.onConfirm(items, _groupId);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text("Done"),
|
||||
|
|
|
|||
171
test/group_filter_test.dart
Normal file
171
test/group_filter_test.dart
Normal file
|
|
@ -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<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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue