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 createState() => _FilterEditorScreenState(); } class _FilterEditorScreenState extends State { static final _logger = Logger('$FilterEditorScreen'); final nameController = TextEditingController(); var action = TimelineEntryFilterAction.hide; final filteredAuthors = []; final filteredDomains = []; final filteredKeywords = []; final filteredHashtags = []; @override void initState() { super.initState(); if (widget.id.isEmpty) { return; } final filter = getIt>() .activeEntry .andThen((tfs) => tfs.getForId(widget.id)) .value; final cm = getIt>().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>() .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( 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 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.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 (@@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.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 (#)', 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; } } }