relatica/lib/screens/filter_editor_screen.dart

542 wiersze
21 KiB
Dart

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 'package:provider/provider.dart';
import '../controls/autocomplete/hashtag_autocomplete_options.dart';
import '../controls/autocomplete/mention_autocomplete_options.dart';
import '../controls/image_control.dart';
import '../controls/padding.dart';
import '../globals.dart';
import '../models/connection.dart';
import '../models/filters/string_filter.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';
class FilterEditorScreen extends StatefulWidget {
final String id;
const FilterEditorScreen({super.key, required this.id});
@override
State<FilterEditorScreen> createState() => _FilterEditorScreenState();
}
class _FilterEditorScreenState extends State<FilterEditorScreen> {
static final _logger = Logger('$FilterEditorScreen');
final nameController = TextEditingController();
var action = TimelineEntryFilterAction.hide;
final filteredAuthors = <Connection>[];
final filteredDomains = <String>[];
final filteredKeywords = <String>[];
final filteredHashtags = <String>[];
@override
void initState() {
super.initState();
if (widget.id.isEmpty) {
return;
}
final filter = getIt<ActiveProfileSelector<TimelineEntryFilterService>>()
.activeEntry
.andThen((tfs) => tfs.getForId(widget.id))
.value;
final cm =
getIt<ActiveProfileSelector<ConnectionsManager>>().activeEntry.value;
nameController.text = filter.name;
action = filter.action;
for (final f in filter.authorFilters) {
cm.getById(f.filterString).withResult((c) => filteredAuthors.add(c));
}
filteredDomains.addAll(
filter.domainFilters.map((f) => f.toLabel()),
);
filteredKeywords.addAll(
filter.keywordFilters.map((f) => f.toLabel()),
);
filteredHashtags.addAll(
filter.hashtagFilters.map((f) => f.toLabel()),
);
}
@override
Widget build(BuildContext context) {
_logger.finest('Build for filter ${widget.id}');
final fieldWidth = MediaQuery.of(context).size.width * 0.8;
final service = context
.watch<ActiveProfileSelector<TimelineEntryFilterService>>()
.activeEntry
.value;
return Scaffold(
appBar: AppBar(
title: Text(widget.id.isEmpty ? 'New Filter' : 'Edit Filter'),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SingleChildScrollView(
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,
onSelected: (value) => action = value!,
dropdownMenuEntries: TimelineEntryFilterAction.values
.map((a) =>
DropdownMenuEntry(value: a, label: a.toLabel()))
.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: [
ElevatedButton(
onPressed: () {
final update = TimelineEntryFilter.create(
id: widget.id.isNotEmpty ? widget.id : null,
action: action,
name: nameController.text,
enabled: true,
authors: filteredAuthors,
hashtags: filteredHashtags,
keywords: filteredKeywords,
domains: filteredDomains,
);
service.upsertFilter(update);
if (context.canPop()) {
context.pop();
}
},
child: Text(widget.id.isEmpty ? 'Add' : 'Update')),
],
),
const VerticalPadding(),
],
),
)),
),
);
}
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.top,
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.top,
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'),
),
],
);
});
}
}
extension StringFilterLabel on StringFilter {
String toLabel() {
switch (type) {
case ComparisonType.endsWithIgnoreCase:
return '*$filterString';
case ComparisonType.contains:
case ComparisonType.containsIgnoreCase:
case ComparisonType.equals:
case ComparisonType.equalsIgnoreCase:
return filterString;
}
}
}