Initial filters screen

codemagic-setup
Hank Grabowski 2023-05-07 19:07:32 -04:00
rodzic 155500fd70
commit d2757a7664
11 zmienionych plików z 696 dodań i 15 usunięć

Wyświetl plik

@ -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<FilterControl> createState() => _FilterControlState();
}
class _FilterControlState extends State<FilterControl> {
static final _logger = Logger('$FilterControl');
final nameController = TextEditingController();
var action = TimelineEntryFilterAction.hide;
final filteredAuthors = <Connection>[];
final filteredDomains = <String>[];
final filteredKeywords = <String>[];
final filteredHashtags = <String>[];
TimelineEntryFilter get entry => widget.initialEntry;
@override
void initState() {
super.initState();
final cm =
getIt<ActiveProfileSelector<ConnectionsManager>>().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<TimelineEntryFilterAction>(
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<String?> 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<Connection?> 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 (@<user>@domain)',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: const BorderSide(),
borderRadius: BorderRadius.circular(5.0),
),
),
),
),
actions: [
ElevatedButton(
onPressed: () {
final rval =
getIt<ActiveProfileSelector<ConnectionsManager>>()
.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<String?> 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 (#<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'),
),
],
);
});
}
}

Wyświetl plik

@ -79,6 +79,11 @@ class StandardAppDrawer extends StatelessWidget {
'Blocks',
() => context.pushNamed(ScreenPaths.blocks),
),
buildMenuButton(
context,
'Filters',
() => context.pushNamed(ScreenPaths.filters),
),
buildMenuButton(
context,
'Groups Management',

Wyświetl plik

@ -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<void> dependencyInjectionInitialization() async {
},
),
);
getIt.registerSingleton<ActiveProfileSelector<TimelineEntryFilterService>>(
ActiveProfileSelector(
(profile) {
final profilePersistencePath =
p.join(appSupportdir.path, '${profile.id}_filters.json');
return TimelineEntryFilterService(profilePersistencePath)..load();
},
),
);
final objectBoxCache = await ObjectBoxCache.create();
getIt.registerSingleton<ObjectBoxCache>(objectBoxCache);
getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo());

Wyświetl plik

@ -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<ActiveProfileSelector<BlocksManager>>(
create: (_) => getIt<ActiveProfileSelector<BlocksManager>>(),
),
ChangeNotifierProvider<
ActiveProfileSelector<TimelineEntryFilterService>>(
create: (_) =>
getIt<ActiveProfileSelector<TimelineEntryFilterService>>(),
),
],
child: MaterialApp.router(
useInheritedMediaQuery: true,

Wyświetl plik

@ -25,7 +25,7 @@ class StringFilter {
Map<String, dynamic> toJson() => {
'filterString': filterString,
'type': type,
'type': type.name,
};
factory StringFilter.fromJson(Map<String, dynamic> json) => StringFilter(

Wyświetl plik

@ -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<StringFilter> authorFilters;
final List<StringFilter> domainFilters;
final List<StringFilter> contentFilters;
final List<StringFilter> keywordFilters;
final List<StringFilter> 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<Connection> authors = const [],
@ -40,6 +45,7 @@ class TimelineEntryFilter {
List<String> 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<String, dynamic> 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<String, dynamic> json) =>
TimelineEntryFilter(
id: json['id'],
action: TimelineEntryFilterAction.parse(json['action']),
name: json['name'],
authorFilters: (json['authorFilters'] as List<dynamic>)
@ -87,7 +105,7 @@ class TimelineEntryFilter {
domainFilters: (json['domainFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
contentFilters: (json['contentFilters'] as List<dynamic>)
keywordFilters: (json['keywordFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
hashtagFilters: (json['hashtagFilters'] as List<dynamic>)

Wyświetl plik

@ -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,

Wyświetl plik

@ -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<ActiveProfileSelector<TimelineEntryFilterService>>()
.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),
),
],
),
),
);
}
}

Wyświetl plik

@ -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 = <TimelineEntryFilter>{};
final _entryCache = <String, _EntryCacheItem>{};
List<TimelineEntryFilter> 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<dynamic>;
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);
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -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',