kopia lustrzana https://gitlab.com/mysocialportal/relatica
542 wiersze
21 KiB
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.finer('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;
|
|
}
|
|
}
|
|
}
|