kopia lustrzana https://gitlab.com/mysocialportal/relatica
Refactor Filter UI into List screen w/summary and editor screen
rodzic
d2757a7664
commit
6644fee523
|
@ -1,495 +0,0 @@
|
|||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -14,6 +14,24 @@ enum TimelineEntryFilterAction {
|
|||
orElse: () => warn,
|
||||
);
|
||||
}
|
||||
|
||||
String toLabel() {
|
||||
switch (this) {
|
||||
case TimelineEntryFilterAction.hide:
|
||||
return 'Hide';
|
||||
case TimelineEntryFilterAction.warn:
|
||||
return 'Warn';
|
||||
}
|
||||
}
|
||||
|
||||
String toVerb() {
|
||||
switch (this) {
|
||||
case TimelineEntryFilterAction.hide:
|
||||
return 'Hiding';
|
||||
case TimelineEntryFilterAction.warn:
|
||||
return 'Warning';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineEntryFilter {
|
||||
|
|
|
@ -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/filter_editor_screen.dart';
|
||||
import 'screens/filters_screen.dart';
|
||||
import 'screens/follow_request_adjudication_screen.dart';
|
||||
import 'screens/gallery_browsers_screen.dart';
|
||||
|
@ -86,6 +87,19 @@ final appRouter = GoRouter(
|
|||
path: ScreenPaths.filters,
|
||||
name: ScreenPaths.filters,
|
||||
builder: (context, state) => const FiltersScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'new',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: FilterEditorScreen(id: ''),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'edit/:id',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: FilterEditorScreen(id: state.params['id']!)),
|
||||
)
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: ScreenPaths.signin,
|
||||
|
|
|
@ -0,0 +1,524 @@
|
|||
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/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.filterString),
|
||||
);
|
||||
|
||||
filteredKeywords.addAll(
|
||||
filter.keywordFilters.map((f) => f.filterString),
|
||||
);
|
||||
|
||||
filteredHashtags.addAll(
|
||||
filter.hashtagFilters.map((f) => f.filterString),
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
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,
|
||||
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.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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../controls/filter_control.dart';
|
||||
import '../globals.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';
|
||||
|
||||
|
@ -23,10 +25,7 @@ class FiltersScreen extends StatelessWidget {
|
|||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
service.upsertFilter(
|
||||
TimelineEntryFilter.create(
|
||||
action: TimelineEntryFilterAction.warn, name: 'New Filter'),
|
||||
);
|
||||
context.push('/filters/new');
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
|
@ -39,13 +38,7 @@ class FiltersScreen extends StatelessWidget {
|
|||
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),
|
||||
);
|
||||
return buildFilterSummary(context, filters[index], service);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(),
|
||||
itemCount: filters.length),
|
||||
|
@ -55,4 +48,53 @@ class FiltersScreen extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFilterSummary(BuildContext context, TimelineEntryFilter filter,
|
||||
TimelineEntryFilterService service) {
|
||||
return ListTile(
|
||||
title: Text('${filter.action.toVerb()} Filter: ${filter.name}'),
|
||||
subtitle: Text(
|
||||
filter.toSummaryText(),
|
||||
maxLines: 10,
|
||||
softWrap: true,
|
||||
),
|
||||
trailing: IconButton(
|
||||
onPressed: () async {
|
||||
final confirm =
|
||||
await showYesNoDialog(context, 'Delete filter ${filter.name}?');
|
||||
if (confirm == true) {
|
||||
service.removeById(filter.id);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove)),
|
||||
onTap: () => context.push('/filters/edit/${filter.id}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _TimelineEntryFilterSummary on TimelineEntryFilter {
|
||||
String toSummaryText() {
|
||||
var authorsString = '';
|
||||
if (authorFilters.isNotEmpty) {
|
||||
final cm =
|
||||
getIt<ActiveProfileSelector<ConnectionsManager>>().activeEntry.value;
|
||||
authorsString = authorFilters
|
||||
.map((a) => cm
|
||||
.getById(a.filterString)
|
||||
.transform((c) => '${c.name} (${c.handle})')
|
||||
.getValueOrElse(() => ''))
|
||||
.where((e) => e.isNotEmpty)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
return [
|
||||
if (hashtagFilters.isNotEmpty)
|
||||
'Hashtags: ${hashtagFilters.map((f) => f.filterString).join(',')}',
|
||||
if (keywordFilters.isNotEmpty)
|
||||
'Keywords: ${keywordFilters.map((f) => f.filterString).join(',')}',
|
||||
if (domainFilters.isNotEmpty)
|
||||
'Domains: ${domainFilters.map((f) => f.filterString).join(', ')}',
|
||||
if (authorFilters.isNotEmpty) 'Authors: $authorsString',
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:result_monad/result_monad.dart';
|
||||
|
||||
import '../models/exec_error.dart';
|
||||
import '../models/filters/timeline_entry_filter.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../utils/filter_runner.dart';
|
||||
|
@ -12,10 +14,11 @@ import '../utils/filter_runner.dart';
|
|||
class TimelineEntryFilterService extends ChangeNotifier {
|
||||
static final _logger = Logger('$TimelineEntryFilterService');
|
||||
final String filePath;
|
||||
final _filters = <TimelineEntryFilter>{};
|
||||
final _filters = <String, TimelineEntryFilter>{};
|
||||
final _entryCache = <String, _EntryCacheItem>{};
|
||||
|
||||
List<TimelineEntryFilter> get filters => UnmodifiableListView(_filters);
|
||||
List<TimelineEntryFilter> get filters =>
|
||||
UnmodifiableListView(_filters.values);
|
||||
|
||||
TimelineEntryFilterService(this.filePath);
|
||||
|
||||
|
@ -30,7 +33,7 @@ class TimelineEntryFilterService extends ChangeNotifier {
|
|||
final json = jsonDecode(str) as List<dynamic>;
|
||||
final filters = json.map((j) => TimelineEntryFilter.fromJson(j)).toList();
|
||||
_filters.clear();
|
||||
_filters.addAll(filters);
|
||||
_filters.addEntries(filters.map((f) => MapEntry(f.id, f)));
|
||||
} catch (e) {
|
||||
_logger.severe('Error parsing filters file $filePath: $e');
|
||||
}
|
||||
|
@ -38,7 +41,7 @@ class TimelineEntryFilterService extends ChangeNotifier {
|
|||
|
||||
void save() {
|
||||
try {
|
||||
final json = _filters.map((f) => f.toJson()).toList();
|
||||
final json = _filters.values.map((f) => f.toJson()).toList();
|
||||
final str = jsonEncode(json);
|
||||
File(filePath).writeAsStringSync(str);
|
||||
} catch (e) {
|
||||
|
@ -46,16 +49,26 @@ class TimelineEntryFilterService extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Result<TimelineEntryFilter, ExecError> getForId(String id) {
|
||||
if (!_filters.containsKey(id)) {
|
||||
return buildErrorResult(
|
||||
type: ErrorType.notFound,
|
||||
message: 'No filter with id: $id',
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(_filters[id]!);
|
||||
}
|
||||
|
||||
void upsertFilter(TimelineEntryFilter filter) {
|
||||
_filters.remove(filter);
|
||||
_filters.add(filter);
|
||||
_filters[filter.id] = filter;
|
||||
_entryCache.clear();
|
||||
save();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeFilter(TimelineEntryFilter filter) {
|
||||
_filters.remove(filter);
|
||||
void removeById(String id) {
|
||||
_filters.remove(id);
|
||||
_entryCache.clear();
|
||||
save();
|
||||
notifyListeners();
|
||||
|
@ -66,7 +79,7 @@ class TimelineEntryFilterService extends ChangeNotifier {
|
|||
return _entryCache[entry.id]!.result;
|
||||
}
|
||||
|
||||
final result = runFilters(entry, _filters.toList());
|
||||
final result = runFilters(entry, _filters.values.toList());
|
||||
_entryCache[entry.id] = _EntryCacheItem(entry, result);
|
||||
|
||||
return result;
|
||||
|
|
Ładowanie…
Reference in New Issue