diff --git a/lib/controls/filter_control.dart b/lib/controls/filter_control.dart new file mode 100644 index 0000000..c57575a --- /dev/null +++ b/lib/controls/filter_control.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart'; + +import '../globals.dart'; +import '../models/connection.dart'; +import '../models/filters/timeline_entry_filter.dart'; +import '../services/connections_manager.dart'; +import '../services/timeline_entry_filter_service.dart'; +import '../utils/active_profile_selector.dart'; +import '../utils/snackbar_builder.dart'; +import 'autocomplete/hashtag_autocomplete_options.dart'; +import 'autocomplete/mention_autocomplete_options.dart'; +import 'image_control.dart'; +import 'padding.dart'; + +class FilterControl extends StatefulWidget { + final TimelineEntryFilter initialEntry; + final TimelineEntryFilterService service; + final Function(TimelineEntryFilter)? onUpdate; + final Function(TimelineEntryFilter)? onRemove; + + const FilterControl({ + super.key, + required this.initialEntry, + required this.service, + this.onUpdate, + this.onRemove, + }); + + @override + State createState() => _FilterControlState(); +} + +class _FilterControlState extends State { + static final _logger = Logger('$FilterControl'); + final nameController = TextEditingController(); + var action = TimelineEntryFilterAction.hide; + final filteredAuthors = []; + final filteredDomains = []; + final filteredKeywords = []; + final filteredHashtags = []; + + TimelineEntryFilter get entry => widget.initialEntry; + + @override + void initState() { + super.initState(); + final cm = + getIt>().activeEntry.value; + nameController.text = widget.initialEntry.name; + action = widget.initialEntry.action; + for (final f in widget.initialEntry.authorFilters) { + cm.getById(f.filterString).withResult((c) => filteredAuthors.add(c)); + } + filteredDomains.addAll( + widget.initialEntry.domainFilters.map((f) => f.filterString), + ); + + filteredKeywords.addAll( + widget.initialEntry.keywordFilters.map((f) => f.filterString), + ); + + filteredHashtags.addAll( + widget.initialEntry.hashtagFilters.map((f) => f.filterString), + ); + } + + @override + Widget build(BuildContext context) { + _logger.finer( + 'Build for filter ${widget.initialEntry.id} ${widget.initialEntry.name}'); + final fieldWidth = MediaQuery.of(context).size.width * 0.8; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Row( + children: [ + const HorizontalPadding(), + Expanded( + child: TextField( + controller: nameController, + decoration: InputDecoration( + labelText: 'Name of filter', + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + ], + ), + const VerticalPadding(), + const Text('Action:'), + DropdownMenu( + initialSelection: action, + dropdownMenuEntries: TimelineEntryFilterAction.values + .map((a) => DropdownMenuEntry(value: a, label: a.name)) + .toList()), + const VerticalPadding(), + const Text('Authors:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newConnection = await promptForConnection(context); + if (!mounted) { + return; + } + + if (newConnection == null) { + return; + } + + if (filteredAuthors.contains(newConnection)) { + buildSnackbar( + context, + 'Already filtering on ${newConnection.handle}', + ); + } + setState(() { + filteredAuthors.add(newConnection); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredAuthors.map( + (a) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + ImageControl( + imageUrl: a.avatarUrl.toString(), + iconOverride: const Icon(Icons.person), + width: 24.0, + ), + const HorizontalPadding( + width: 2.0, + ), + Flexible( + child: Text( + '${a.name} (${a.handle})', + softWrap: true, + maxLines: 10, + ), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredAuthors.remove(a); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + const Text('Hashtags:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newValue = await promptForHashtag(context); + if (newValue == null || newValue.isEmpty) { + return; + } + setState(() { + filteredHashtags.add(newValue); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredHashtags.map( + (h) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Flexible( + child: Text(h, softWrap: true, maxLines: 10), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredHashtags.remove(h); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + const Text('Keywords:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newValue = await promptForString(context); + if (newValue == null || newValue.isEmpty) { + return; + } + setState(() { + filteredKeywords.add(newValue); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredKeywords.map( + (k) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Flexible( + child: Text(k, softWrap: true, maxLines: 10), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredKeywords.remove(k); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + const Text('Domains:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newValue = await promptForString(context); + if (newValue == null || newValue.isEmpty) { + return; + } + setState(() { + filteredDomains.add(newValue); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredDomains.map( + (d) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Flexible( + child: Text(d, softWrap: true, maxLines: 10), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredDomains.remove(d); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.onUpdate != null) ...[ + ElevatedButton( + onPressed: () => + widget.onUpdate!(TimelineEntryFilter.create( + id: widget.initialEntry.id, + action: action, + name: nameController.text, + authors: filteredAuthors, + hashtags: filteredHashtags, + keywords: filteredKeywords, + domains: filteredDomains, + )), + child: const Text('Update')), + const HorizontalPadding() + ], + if (widget.onRemove != null) + ElevatedButton( + onPressed: () => widget.onRemove!(widget.initialEntry), + child: const Text('Remove')), + ], + ) + ], + ), + ); + } + + Future promptForString(BuildContext context) async { + return await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + final controller = TextEditingController(); + return AlertDialog( + content: TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'Enter value', + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: () => context.pop(controller.text), + child: const Text('OK'), + ), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + ], + ); + }); + } + + Future promptForConnection(BuildContext context) async { + final focusNode = FocusNode(); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final controller = TextEditingController(); + return AlertDialog( + content: MultiTriggerAutocomplete( + textEditingController: controller, + focusNode: focusNode, + optionsAlignment: OptionsAlignment.bottomEnd, + autocompleteTriggers: [ + AutocompleteTrigger( + trigger: '@', + triggerOnlyAfterSpace: false, + optionsViewBuilder: + (ovbContext, autocompleteQuery, controller) { + return MentionAutocompleteOptions( + query: autocompleteQuery.query, + onMentionUserTap: (user) { + final autocomplete = + MultiTriggerAutocomplete.of(ovbContext); + return autocomplete + .acceptAutocompleteOption(user.handle); + }, + ); + }, + ), + ], + fieldViewBuilder: (fvbContext, controller, focusNode) => + TextFormField( + focusNode: focusNode, + controller: controller, + decoration: InputDecoration( + labelText: 'Author (@@domain)', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: () { + final rval = + getIt>() + .activeEntry + .andThen((cm) { + var handle = controller.text.trim(); + if (handle.startsWith('@')) { + handle = handle.substring(1); + } + return cm.getByHandle(handle); + }) + .withError((error) => buildSnackbar(context, + "Error adding ${controller.text}: $error")) + .fold(onSuccess: (c) => c, onError: (_) => null); + dialogContext.pop(rval); + }, + child: const Text('OK'), + ), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + ], + ); + }); + } + + Future promptForHashtag(BuildContext context) async { + final focusNode = FocusNode(); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final controller = TextEditingController(); + return AlertDialog( + content: MultiTriggerAutocomplete( + textEditingController: controller, + focusNode: focusNode, + optionsAlignment: OptionsAlignment.bottomEnd, + autocompleteTriggers: [ + AutocompleteTrigger( + trigger: '#', + triggerOnlyAfterSpace: false, + optionsViewBuilder: + (ovbContext, autocompleteQuery, controller) { + return HashtagAutocompleteOptions( + query: autocompleteQuery.query, + onHashtagTap: (hashtag) { + final autocomplete = + MultiTriggerAutocomplete.of(ovbContext); + return autocomplete.acceptAutocompleteOption(hashtag); + }, + ); + }, + ), + ], + fieldViewBuilder: (fvbContext, controller, focusNode) => + TextFormField( + focusNode: focusNode, + controller: controller, + decoration: InputDecoration( + labelText: 'Hashtag (#)', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: () { + final rval = controller.text.trim(); + if (rval.startsWith('#')) { + dialogContext.pop(rval.substring(1)); + } else { + dialogContext.pop(rval); + } + }, + child: const Text('OK'), + ), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + ], + ); + }); + } +} diff --git a/lib/controls/standard_app_drawer.dart b/lib/controls/standard_app_drawer.dart index 23eafa3..4c57eb4 100644 --- a/lib/controls/standard_app_drawer.dart +++ b/lib/controls/standard_app_drawer.dart @@ -79,6 +79,11 @@ class StandardAppDrawer extends StatelessWidget { 'Blocks', () => context.pushNamed(ScreenPaths.blocks), ), + buildMenuButton( + context, + 'Filters', + () => context.pushNamed(ScreenPaths.filters), + ), buildMenuButton( context, 'Groups Management', diff --git a/lib/di_initialization.dart b/lib/di_initialization.dart index 5f61aac..29cbd28 100644 --- a/lib/di_initialization.dart +++ b/lib/di_initialization.dart @@ -31,6 +31,7 @@ import 'services/notifications_manager.dart'; import 'services/persistent_info_service.dart'; import 'services/secrets_service.dart'; import 'services/setting_service.dart'; +import 'services/timeline_entry_filter_service.dart'; import 'services/timeline_manager.dart'; import 'update_timer_initialization.dart'; import 'utils/active_profile_selector.dart'; @@ -48,6 +49,16 @@ Future dependencyInjectionInitialization() async { }, ), ); + + getIt.registerSingleton>( + ActiveProfileSelector( + (profile) { + final profilePersistencePath = + p.join(appSupportdir.path, '${profile.id}_filters.json'); + return TimelineEntryFilterService(profilePersistencePath)..load(); + }, + ), + ); final objectBoxCache = await ObjectBoxCache.create(); getIt.registerSingleton(objectBoxCache); getIt.registerSingleton(ObjectBoxHashtagRepo()); diff --git a/lib/main.dart b/lib/main.dart index 684adac..481ff5d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'services/hashtag_service.dart'; import 'services/interactions_manager.dart'; import 'services/notifications_manager.dart'; import 'services/setting_service.dart'; +import 'services/timeline_entry_filter_service.dart'; import 'services/timeline_manager.dart'; import 'utils/active_profile_selector.dart'; import 'utils/app_scrolling_behavior.dart'; @@ -115,6 +116,11 @@ class App extends StatelessWidget { ChangeNotifierProvider>( create: (_) => getIt>(), ), + ChangeNotifierProvider< + ActiveProfileSelector>( + create: (_) => + getIt>(), + ), ], child: MaterialApp.router( useInheritedMediaQuery: true, diff --git a/lib/models/filters/string_filter.dart b/lib/models/filters/string_filter.dart index 05879d1..eeb32e7 100644 --- a/lib/models/filters/string_filter.dart +++ b/lib/models/filters/string_filter.dart @@ -25,7 +25,7 @@ class StringFilter { Map toJson() => { 'filterString': filterString, - 'type': type, + 'type': type.name, }; factory StringFilter.fromJson(Map json) => StringFilter( diff --git a/lib/models/filters/timeline_entry_filter.dart b/lib/models/filters/timeline_entry_filter.dart index 284b2bf..78885ef 100644 --- a/lib/models/filters/timeline_entry_filter.dart +++ b/lib/models/filters/timeline_entry_filter.dart @@ -1,3 +1,5 @@ +import 'package:uuid/uuid.dart'; + import '../connection.dart'; import 'string_filter.dart'; @@ -15,23 +17,26 @@ enum TimelineEntryFilterAction { } class TimelineEntryFilter { + final String id; final TimelineEntryFilterAction action; final String name; final List authorFilters; final List domainFilters; - final List contentFilters; + final List keywordFilters; final List hashtagFilters; const TimelineEntryFilter({ + required this.id, required this.action, required this.name, required this.authorFilters, required this.domainFilters, - required this.contentFilters, + required this.keywordFilters, required this.hashtagFilters, }); factory TimelineEntryFilter.create({ + String? id, required TimelineEntryFilterAction action, required String name, List authors = const [], @@ -40,6 +45,7 @@ class TimelineEntryFilter { List hashtags = const [], }) { return TimelineEntryFilter( + id: id ?? const Uuid().v4(), action: action, name: name, authorFilters: authors @@ -57,7 +63,7 @@ class TimelineEntryFilter { type: ComparisonType.equalsIgnoreCase, )) .toList(), - contentFilters: keywords + keywordFilters: keywords .map((k) => StringFilter( filterString: k, type: ComparisonType.containsIgnoreCase)) .toList(), @@ -68,17 +74,29 @@ class TimelineEntryFilter { ); } + @override + bool operator ==(Object other) => + identical(this, other) || + other is TimelineEntryFilter && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; + Map toJson() => { + 'id': id, 'action': action.name, 'name': name, - 'authorFilters': authorFilters.map((f) => f.toJson()), - 'domainFilters': domainFilters.map((f) => f.toJson()), - 'contentFilters': contentFilters.map((f) => f.toJson()), - 'hashtagFilters': hashtagFilters.map((f) => f.toJson()), + 'authorFilters': authorFilters.map((f) => f.toJson()).toList(), + 'domainFilters': domainFilters.map((f) => f.toJson()).toList(), + 'keywordFilters': keywordFilters.map((f) => f.toJson()).toList(), + 'hashtagFilters': hashtagFilters.map((f) => f.toJson()).toList(), }; factory TimelineEntryFilter.fromJson(Map json) => TimelineEntryFilter( + id: json['id'], action: TimelineEntryFilterAction.parse(json['action']), name: json['name'], authorFilters: (json['authorFilters'] as List) @@ -87,7 +105,7 @@ class TimelineEntryFilter { domainFilters: (json['domainFilters'] as List) .map((json) => StringFilter.fromJson(json)) .toList(), - contentFilters: (json['contentFilters'] as List) + keywordFilters: (json['keywordFilters'] as List) .map((json) => StringFilter.fromJson(json)) .toList(), hashtagFilters: (json['hashtagFilters'] as List) diff --git a/lib/routes.dart b/lib/routes.dart index 0801cd2..323a3e4 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -5,6 +5,7 @@ import 'models/interaction_type_enum.dart'; import 'screens/blocks_screen.dart'; import 'screens/contacts_screen.dart'; import 'screens/editor.dart'; +import 'screens/filters_screen.dart'; import 'screens/follow_request_adjudication_screen.dart'; import 'screens/gallery_browsers_screen.dart'; import 'screens/gallery_screen.dart'; @@ -30,6 +31,7 @@ import 'services/auth_service.dart'; class ScreenPaths { static String blocks = '/blocks'; + static String filters = '/filters'; static String thread = '/thread'; static String connectHandle = '/connect'; static String contacts = '/contacts'; @@ -80,6 +82,11 @@ final appRouter = GoRouter( name: ScreenPaths.blocks, builder: (context, state) => const BlocksScreen(), ), + GoRoute( + path: ScreenPaths.filters, + name: ScreenPaths.filters, + builder: (context, state) => const FiltersScreen(), + ), GoRoute( path: ScreenPaths.signin, name: ScreenPaths.signin, diff --git a/lib/screens/filters_screen.dart b/lib/screens/filters_screen.dart new file mode 100644 index 0000000..d7f592e --- /dev/null +++ b/lib/screens/filters_screen.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../controls/filter_control.dart'; +import '../models/filters/timeline_entry_filter.dart'; +import '../services/timeline_entry_filter_service.dart'; +import '../utils/active_profile_selector.dart'; + +class FiltersScreen extends StatelessWidget { + const FiltersScreen({super.key}); + + @override + Widget build(BuildContext context) { + final service = context + .watch>() + .activeEntry + .value; + + final filters = service.filters; + return Scaffold( + appBar: AppBar( + title: const Text('Filters'), + actions: [ + IconButton( + onPressed: () { + service.upsertFilter( + TimelineEntryFilter.create( + action: TimelineEntryFilterAction.warn, name: 'New Filter'), + ); + }, + icon: const Icon(Icons.add), + ), + ], + ), + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: ListView.separated( + itemBuilder: (context, index) { + final filter = filters[index]; + return FilterControl( + initialEntry: filter, + service: service, + onUpdate: (update) => service.upsertFilter(update), + onRemove: (_) => service.removeFilter(filter), + ); + }, + separatorBuilder: (_, __) => const Divider(), + itemCount: filters.length), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/timeline_entry_filter_service.dart b/lib/services/timeline_entry_filter_service.dart new file mode 100644 index 0000000..5874a76 --- /dev/null +++ b/lib/services/timeline_entry_filter_service.dart @@ -0,0 +1,81 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import '../models/filters/timeline_entry_filter.dart'; +import '../models/timeline_entry.dart'; +import '../utils/filter_runner.dart'; + +class TimelineEntryFilterService extends ChangeNotifier { + static final _logger = Logger('$TimelineEntryFilterService'); + final String filePath; + final _filters = {}; + final _entryCache = {}; + + List get filters => UnmodifiableListView(_filters); + + TimelineEntryFilterService(this.filePath); + + void load() { + final file = File(filePath); + if (!file.existsSync()) { + return; + } + + try { + final str = file.readAsStringSync(); + final json = jsonDecode(str) as List; + final filters = json.map((j) => TimelineEntryFilter.fromJson(j)).toList(); + _filters.clear(); + _filters.addAll(filters); + } catch (e) { + _logger.severe('Error parsing filters file $filePath: $e'); + } + } + + void save() { + try { + final json = _filters.map((f) => f.toJson()).toList(); + final str = jsonEncode(json); + File(filePath).writeAsStringSync(str); + } catch (e) { + _logger.severe('Error writing filters file $filePath: $e'); + } + } + + void upsertFilter(TimelineEntryFilter filter) { + _filters.remove(filter); + _filters.add(filter); + _entryCache.clear(); + save(); + notifyListeners(); + } + + void removeFilter(TimelineEntryFilter filter) { + _filters.remove(filter); + _entryCache.clear(); + save(); + notifyListeners(); + } + + FilterResult checkEntry(TimelineEntry entry) { + if (entry == _entryCache[entry.id]?.entry) { + return _entryCache[entry.id]!.result; + } + + final result = runFilters(entry, _filters.toList()); + _entryCache[entry.id] = _EntryCacheItem(entry, result); + + return result; + } +} + +class _EntryCacheItem { + final TimelineEntry entry; + final FilterResult result; + + _EntryCacheItem(this.entry, this.result); +} diff --git a/lib/utils/filter_runner.dart b/lib/utils/filter_runner.dart index 11c611f..f08a272 100644 --- a/lib/utils/filter_runner.dart +++ b/lib/utils/filter_runner.dart @@ -65,7 +65,7 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { if (authorFilters.isEmpty && domainFilters.isEmpty && hashtagFilters.isEmpty && - contentFilters.isEmpty) { + keywordFilters.isEmpty) { return false; } @@ -97,8 +97,8 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { } } - var contentFiltered = contentFilters.isEmpty ? true : false; - for (final filter in contentFilters) { + var contentFiltered = keywordFilters.isEmpty ? true : false; + for (final filter in keywordFilters) { if (filter.isFiltered(entry.body)) { contentFiltered = true; break; diff --git a/test/filter_runner_test.dart b/test/filter_runner_test.dart index 43131c1..19813e8 100644 --- a/test/filter_runner_test.dart +++ b/test/filter_runner_test.dart @@ -113,7 +113,7 @@ void main() { final actual = entries.map((e) => filter.isFiltered(e)).toList(); expect(actual, equals(expected)); }); - test('Test Content Filter', () { + test('Test Keyword Filter', () { final filter = TimelineEntryFilter.create( action: TimelineEntryFilterAction.hide, name: 'filter', @@ -170,7 +170,7 @@ void main() { expect(actual, equals(expected)); }); - test('Test Author plus content', () { + test('Test Author plus Keyword', () { final filter = TimelineEntryFilter.create( action: TimelineEntryFilterAction.hide, name: 'filter', @@ -194,7 +194,7 @@ void main() { expect(actual, equals(expected)); }); - test('Test Content plus tag', () { + test('Test Keyword plus tag', () { final filter = TimelineEntryFilter.create( action: TimelineEntryFilterAction.hide, name: 'filter',