From f4c6f428a642923c0eb016c1f877d6898fa76d98 Mon Sep 17 00:00:00 2001 From: jwr1 <47087725+jwr1@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:59:57 -0400 Subject: [PATCH 1/6] Initial rules setup --- lib/l10n/app_en.arb | 43 +- lib/src/controller/controller.dart | 69 ++-- lib/src/controller/profile.dart | 33 +- lib/src/controller/rule.dart | 345 ++++++++++++++++ lib/src/models/config_share.dart | 2 +- lib/src/screens/feed/feed_agregator.dart | 33 +- lib/src/screens/feed/feed_screen.dart | 110 +++-- lib/src/screens/feed/post_item.dart | 7 +- .../settings/feed_settings_screen.dart | 46 ++- .../screens/settings/filter_lists_screen.dart | 386 ------------------ .../screens/settings/profile_selection.dart | 2 +- lib/src/screens/settings/rules_screen.dart | 258 ++++++++++++ lib/src/screens/settings/settings_screen.dart | 8 +- .../widgets/content_item/content_item.dart | 313 +++++--------- .../markdown/markdown_config_share.dart | 38 +- lib/src/widgets/markdown/markdown_editor.dart | 27 +- pubspec.lock | 26 +- pubspec.yaml | 2 +- 18 files changed, 918 insertions(+), 830 deletions(-) create mode 100644 lib/src/controller/rule.dart delete mode 100644 lib/src/screens/settings/filter_lists_screen.dart create mode 100644 lib/src/screens/settings/rules_screen.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 41c43070..0eb4e6b0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -304,7 +304,6 @@ "profile_edit": "Edit profile", "profile_import": "Import profile", "profile_delete": "Delete profile", - "profile_name": "Name", "profile_setAsMain": "Set as main profile", "profile_setAsMain_help": "The main profile serves as the foundation for all other profiles. Changes made to it will apply to all profiles unless a specific setting has been overridden.", "profile_autoSelect": "Automatically select on app start", @@ -497,33 +496,12 @@ } } }, - "filterLists": "Filter lists", - "filterList_activateFilter": "Activate Filter", - "filterList_new": "New filter list", - "filterList_edit": "Edit filter list", - "filterList_import": "Import filter list", - "filterList_delete": "Delete filter list", - "filterList_name": "Name", - "filterList_phrases": "Phrases", - "filterList_addPhrase": "Add phrase", - "filterList_showWithContentWarning": "Show with content warning", - "filterList_matchMode": "Match mode", - "filterList_matchMode_simple": "Simple", - "filterList_matchMode_simple_help": "Will literally match any occurrence of the phrase. For example, the phrase \"app\" will match \"app\", and also \"apps\", \"application\", \"yapp\", etc.", - "filterList_matchMode_wholeWords": "Whole words", - "filterList_matchMode_wholeWords_help": "Will only match if there is a word boundary at both ends of the checked text, in other words, the phrase must be the whole word and cannot be in the middle of a word. For example, the phrase \"app\" will match \"app\", but not \"apps\", \"application\", \"yapp\", etc.", - "filterList_matchMode_regex": "Regular expression", - "filterList_matchMode_regex_help": "Will match content that is found by the specified regular expression, which is derived from the phrase.", - "filterList_caseSensitive": "Case sensitive", - "filterList_caseSensitive_help": "When enabled, will only match content with the same casing as the phrase.", - "filterListWarningX": "Filter list matches: {lists}", - "@filterListWarningX": { - "placeholders": { - "lists": { - "type": "String" - } - } - }, + "rules": "Rules", + "rule_activate": "Activate rule", + "rule_new": "New rule", + "rule_edit": "Edit rule", + "rule_import": "Import rule", + "rule_delete": "Delete rule", "confirmUnsubscribe": "You are about to unsubscribe. Continue?", "confirmUnfollow": "You are about to unfollow. Continue?", "altText": "Alt text", @@ -637,9 +615,9 @@ } } }, - "configShare_filterList_title": "Interstellar filter list", - "configShare_filterList_info": "Contains {count} phrases", - "@configShare_filterList_info": { + "configShare_rule_title": "Interstellar rule", + "configShare_rule_info": "Contains {count} actions", + "@configShare_rule_info": { "placeholders": { "count": { "type": "int" @@ -670,5 +648,6 @@ "errorPage_firstPage_button": "Try Again", "errorPage_newPage": "Something went wrong. Tap to try again.", "errorPage_caughtUp": "You're all caught up.\nLoad older items?", - "exitMessage": "Press BACK again to exit." + "exitMessage": "Press BACK again to exit.", + "name": "Name" } diff --git a/lib/src/controller/controller.dart b/lib/src/controller/controller.dart index c6d33225..ae2c6461 100644 --- a/lib/src/controller/controller.dart +++ b/lib/src/controller/controller.dart @@ -11,6 +11,7 @@ import 'package:interstellar/src/controller/database.dart'; import 'package:interstellar/src/controller/feed.dart'; import 'package:interstellar/src/controller/filter_list.dart'; import 'package:interstellar/src/controller/profile.dart'; +import 'package:interstellar/src/controller/rule.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/utils/jwt_http_client.dart'; @@ -32,7 +33,7 @@ class AppController with ChangeNotifier { final _mainStore = StoreRef.main(); final _accountStore = StoreRef('account'); final _feedStore = StoreRef('feeds'); - final _filterListStore = StoreRef('filterList'); + final _ruleStore = StoreRef('rules'); final _profileStore = StoreRef('profile'); final _serverStore = StoreRef('server'); final _readStore = StoreRef('read'); @@ -88,8 +89,8 @@ class AppController with ChangeNotifier { late Map _feeds; Map get feeds => _feeds; - late Map _filterLists; - Map get filterLists => _filterLists; + late Map _rules; + Map get rules => _rules; late Function refreshState; @@ -110,7 +111,7 @@ class AppController with ChangeNotifier { _logger = Logger( printer: SimplePrinter(printTime: true, colors: false), output: FileOutput(file: await logFile), - filter: ProductionFilter() + filter: ProductionFilter(), ); logger.i('Initializing interstellar'); @@ -175,10 +176,10 @@ class AppController with ChangeNotifier { )).map((record) => MapEntry(record.key, Feed.fromJson(record.value))), ); - _filterLists = Map.fromEntries( - (await _filterListStore.find(db)).map( - (record) => MapEntry(record.key, FilterList.fromJson(record.value)), - ), + _rules = Map.fromEntries( + (await _ruleStore.find( + db, + )).map((record) => MapEntry(record.key, Rule.fromJson(record.value))), ); _translator = SimplyTranslator(EngineType.libre); @@ -581,29 +582,26 @@ class AppController with ChangeNotifier { await _feedStore.record(FieldKey.escape(oldName)).delete(db); } - Future setFilterList(String name, FilterList value) async { - _filterLists[name] = value; + Future setRule(String name, Rule value) async { + _rules[name] = value; notifyListeners(); - await _filterListStore - .record(FieldKey.escape(name)) - .put(db, value.toJson()); + await _ruleStore.record(FieldKey.escape(name)).put(db, value.toJson()); } - Future removeFilterList(String name) async { - _filterLists.remove(name); + Future removeRule(String name) async { + _rules.remove(name); - // Remove a profile's activation value if it is for this filter list + // Remove a profile's activation value if it is for this rule for (var record in await _profileStore.find(db)) { final profile = ProfileOptional.fromJson(record.value); - if (profile.filterLists?.containsKey(name) == true) { - final newProfileFilterLists = {...profile.filterLists!}; + if (profile.rules?.containsKey(name) == true) { + final newProfileFilterLists = {...profile.rules!}; newProfileFilterLists.remove(name); - await _profileRecord(record.key).put( - db, - profile.copyWith(filterLists: newProfileFilterLists).toJson(), - ); + await _profileRecord( + record.key, + ).put(db, profile.copyWith(rules: newProfileFilterLists).toJson()); } } @@ -611,26 +609,25 @@ class AppController with ChangeNotifier { notifyListeners(); - await _filterListStore.record(FieldKey.escape(name)).delete(db); + await _ruleStore.record(FieldKey.escape(name)).delete(db); } - Future renameFilterList(String oldName, String newName) async { - _filterLists[newName] = _filterLists[oldName]!; - _filterLists.remove(oldName); + Future renameRule(String oldName, String newName) async { + _rules[newName] = _rules[oldName]!; + _rules.remove(oldName); // Update a profile's activation value if it is for this filter list for (var record in await _profileStore.find(db)) { final profile = ProfileOptional.fromJson(record.value); - if (profile.filterLists?.containsKey(oldName) == true) { + if (profile.rules?.containsKey(oldName) == true) { final newProfileFilterLists = { - ...profile.filterLists!, - newName: profile.filterLists![oldName]!, + ...profile.rules!, + newName: profile.rules![oldName]!, }; newProfileFilterLists.remove(oldName); - await _profileRecord(record.key).put( - db, - profile.copyWith(filterLists: newProfileFilterLists).toJson(), - ); + await _profileRecord( + record.key, + ).put(db, profile.copyWith(rules: newProfileFilterLists).toJson()); } } @@ -638,10 +635,10 @@ class AppController with ChangeNotifier { notifyListeners(); - await _filterListStore + await _ruleStore .record(FieldKey.escape(newName)) - .put(db, _filterLists[newName]!.toJson()); - await _filterListStore.record(FieldKey.escape(oldName)).delete(db); + .put(db, _rules[newName]!.toJson()); + await _ruleStore.record(FieldKey.escape(oldName)).delete(db); } Future vibrate(HapticsType type) async { diff --git a/lib/src/controller/profile.dart b/lib/src/controller/profile.dart index da322da6..ffc4f4f8 100644 --- a/lib/src/controller/profile.dart +++ b/lib/src/controller/profile.dart @@ -48,8 +48,7 @@ class ProfileRequired with _$ProfileRequired { required bool fullImageSizeThreads, required bool fullImageSizeMicroblogs, // Feed defaults - @FeedViewConverter() - required FeedView feedDefaultView, + @FeedViewConverter() required FeedView feedDefaultView, required FeedSource feedDefaultFilter, required FeedSort feedDefaultThreadsSort, required FeedSort feedDefaultMicroblogSort, @@ -75,7 +74,7 @@ class ProfileRequired with _$ProfileRequired { required SwipeAction swipeActionRightLong, required double swipeActionThreshold, // Filter list activations - required Map filterLists, + required Map rules, required bool showErrors, }) = _ProfileRequired; @@ -139,7 +138,9 @@ class ProfileRequired with _$ProfileRequired { feedDefaultMicroblogSort: profile?.feedDefaultMicroblogSort ?? defaultProfile.feedDefaultMicroblogSort, - feedDefaultCombinedSort: profile?.feedDefaultCombinedSort ?? defaultProfile.feedDefaultCombinedSort, + feedDefaultCombinedSort: + profile?.feedDefaultCombinedSort ?? + defaultProfile.feedDefaultCombinedSort, feedDefaultExploreSort: profile?.feedDefaultExploreSort ?? defaultProfile.feedDefaultExploreSort, @@ -178,7 +179,7 @@ class ProfileRequired with _$ProfileRequired { profile?.swipeActionRightLong ?? defaultProfile.swipeActionRightLong, swipeActionThreshold: profile?.swipeActionThreshold ?? defaultProfile.swipeActionThreshold, - filterLists: profile?.filterLists ?? defaultProfile.filterLists, + rules: profile?.rules ?? defaultProfile.rules, showErrors: profile?.showErrors ?? defaultProfile.showErrors, ); @@ -230,7 +231,7 @@ class ProfileRequired with _$ProfileRequired { swipeActionRightShort: SwipeAction.bookmark, swipeActionRightLong: SwipeAction.reply, swipeActionThreshold: 0.20, - filterLists: {}, + rules: {}, showErrors: true, ); } @@ -270,8 +271,7 @@ class ProfileOptional with _$ProfileOptional { required bool? fullImageSizeThreads, required bool? fullImageSizeMicroblogs, // Feed defaults - @FeedViewConverter() - required FeedView? feedDefaultView, + @FeedViewConverter() required FeedView? feedDefaultView, required FeedSource? feedDefaultFilter, required FeedSort? feedDefaultThreadsSort, required FeedSort? feedDefaultMicroblogSort, @@ -296,7 +296,7 @@ class ProfileOptional with _$ProfileOptional { required SwipeAction? swipeActionRightLong, required double? swipeActionThreshold, // Filter list activations - required Map? filterLists, + required Map? rules, required bool? showErrors, }) = _ProfileOptional; @@ -351,7 +351,7 @@ class ProfileOptional with _$ProfileOptional { swipeActionRightShort: null, swipeActionRightLong: null, swipeActionThreshold: null, - filterLists: null, + rules: null, showErrors: null, ); @@ -397,7 +397,8 @@ class ProfileOptional with _$ProfileOptional { other.feedDefaultThreadsSort ?? feedDefaultThreadsSort, feedDefaultMicroblogSort: other.feedDefaultMicroblogSort ?? feedDefaultMicroblogSort, - feedDefaultCombinedSort: other.feedDefaultCombinedSort ?? feedDefaultCombinedSort, + feedDefaultCombinedSort: + other.feedDefaultCombinedSort ?? feedDefaultCombinedSort, feedDefaultExploreSort: other.feedDefaultExploreSort ?? feedDefaultExploreSort, feedDefaultCommentSort: @@ -428,9 +429,9 @@ class ProfileOptional with _$ProfileOptional { other.swipeActionRightLong ?? this.swipeActionRightLong, swipeActionThreshold: other.swipeActionThreshold ?? this.swipeActionThreshold, - filterLists: filterLists != null && other.filterLists != null - ? {...filterLists!, ...other.filterLists!} - : other.filterLists ?? filterLists, + rules: rules != null && other.rules != null + ? {...rules!, ...other.rules!} + : other.rules ?? rules, showErrors: other.showErrors ?? showErrors, ); } @@ -475,7 +476,7 @@ class ProfileOptional with _$ProfileOptional { // Remove fields that depend on a certain setup ProfileOptional exportReady() { - return copyWith(autoSwitchAccount: null, filterLists: null); + return copyWith(autoSwitchAccount: null, rules: null); } } @@ -496,4 +497,4 @@ class FeedViewConverter implements JsonConverter { @override String toJson(FeedView view) => view.name; -} \ No newline at end of file +} diff --git a/lib/src/controller/rule.dart b/lib/src/controller/rule.dart new file mode 100644 index 00000000..e642fafe --- /dev/null +++ b/lib/src/controller/rule.dart @@ -0,0 +1,345 @@ +import 'package:flutter/cupertino.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/models/post.dart'; +import 'package:interstellar/src/models/user.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:provider/provider.dart'; + +part 'rule.freezed.dart'; +part 'rule.g.dart'; + +enum RuleTrigger { + postOrCommentEncountered, + postEncountered, + commentEncountered, + userEncountered, + communityEncountered, + pushNotificationReceived, +} + +@freezed +class RuleFieldOperator with _$RuleFieldOperator { + const factory RuleFieldOperator({ + required String id, + required String Function(BuildContext context) getName, + required bool Function(T field, T operand) compare, + }) = _RuleFieldOperator; +} + +class RuleField { + String id; + String Function(BuildContext context) getName; + T Function() getter; + + /// Set of triggers field is accessible under. Defaults to all. + List? triggers; + + /// Set of subfields available under field. Defaults to none. + List? subfields; + + /// Set of operators available under field. Defaults to none. + List>? operators; + + RuleField({ + required this.id, + required this.getName, + required this.getter, + this.triggers, + this.subfields, + this.operators, + }); +} + +class RuleFieldNumber extends RuleField { + RuleFieldNumber({ + required super.id, + required super.getName, + required super.getter, + }) : super( + operators: [ + RuleFieldOperator( + id: 'equalTo', + getName: (context) => 'Equal to', + compare: (field, operand) => field == operand, + ), + RuleFieldOperator( + id: 'lessThan', + getName: (context) => 'Less than', + compare: (field, operand) => field < operand, + ), + RuleFieldOperator( + id: 'lessThanOrEqualTo', + getName: (context) => 'Less than or equal to', + compare: (field, operand) => field <= operand, + ), + RuleFieldOperator( + id: 'greaterThan', + getName: (context) => 'Greater than', + compare: (field, operand) => field > operand, + ), + RuleFieldOperator( + id: 'greaterThanOrEqualTo', + getName: (context) => 'Greater than or equal to', + compare: (field, operand) => field >= operand, + ), + RuleFieldOperator( + id: 'divisibleBy', + getName: (context) => 'Divisible by', + compare: (field, operand) => field % operand == 0, + ), + ], + ); +} + +class RuleFieldText extends RuleField { + RuleFieldText({ + required super.id, + required super.getName, + required super.getter, + }) : super( + subfields: [ + RuleFieldNumber( + id: 'charCount', + getName: (context) => 'Character count', + getter: () => getter().length.toDouble(), + ), + RuleFieldNumber( + id: 'wordCount', + getName: (context) => 'Word count', + getter: () => + RegExp(r'[\w-]+').allMatches(getter()).length.toDouble(), + ), + RuleFieldNumber( + id: 'lineCount', + getName: (context) => 'Line count', + getter: () => + getter() + .trim() + .split('\n') + .where((line) => line.trim().isNotEmpty) + .length + + 1, + ), + ], + operators: [ + RuleFieldOperator( + id: 'equalTo', + getName: (context) => 'Equal to', + compare: (field, operand) => field == operand, + ), + RuleFieldOperator( + id: 'contains', + getName: (context) => 'Less than', + compare: (field, operand) => field.contains(operand), + ), + RuleFieldOperator( + id: 'startsWith', + getName: (context) => 'Starts with', + compare: (field, operand) => field.startsWith(operand), + ), + RuleFieldOperator( + id: 'endsWith', + getName: (context) => 'Ends with', + compare: (field, operand) => field.endsWith(operand), + ), + RuleFieldOperator( + id: 'matchesRegex', + getName: (context) => 'Matches RegEx', + compare: (field, operand) => RegExp(operand).hasMatch(field), + ), + ], + ); +} + +@freezed +class RuleFieldRootContext with _$RuleFieldRootContext { + const factory RuleFieldRootContext({ + required UserModel user, + required PostModel post, + }) = _RuleFieldRootContext; +} + +class RuleFieldRoot extends RuleField { + RuleFieldRoot({required super.getter}) + : super( + id: '', + getName: (context) => '', + subfields: [ + RuleFieldText( + id: 'body', + getName: (context) => 'Body', + getter: () => getter().post.body ?? '', + ), + RuleFieldNumber( + id: 'upvotes', + getName: (context) => 'Upvotes', + getter: () => getter().post.upvotes?.toDouble() ?? 0, + ), + RuleField( + id: 'user', + getName: (context) => 'User', + getter: () => getter().user, + subfields: [ + RuleFieldText( + id: 'name', + getName: (context) => 'Name', + getter: () => getter().user.name, + ), + ], + ), + ], + ); +} + +// enum RuleConditionField { +// context, + +// body, +// title, +// lang, + +// link, + +// imageSrc, +// imageAlt, + +// userName, +// userAvatarSrc, +// userCreatedAt, +// userIsBot, + +// communityName, +// communityIconSrc, + +// upVotes, +// downVotes, +// points, +// boosts, +// numComments, + +// createdAt, +// editedAt, +// lastActiveAt, + +// isNsfw, +// isPinned, +// isRead, +// canMod, +// } + +// enum RuleConditionNameSubField { full, local, global } + +// enum RuleConditionUriSubField { full, scheme, host, path, query, fragment } + +// enum RuleConditionOperator { +// and, +// or, + +// equals, +// contains, +// startsWith, +// endsWith, +// matchesRegex, + +// greaterThan, +// greaterThanOrEqualTo, +// lessThan, +// lessThanOrEqualTo, +// inRangeOf, +// } + +@freezed +class RuleCondition with _$Rule { + const RuleCondition._(); + + @JsonSerializable(explicitToJson: true, includeIfNull: false) + const factory RuleCondition({ + required bool? not, + + required List? and, + + required List? or, + + required String? field, + required String? operator, + required Object? operand, + }) = _RuleCondition; + + factory RuleCondition.fromJson(JsonMap json) => _$RuleConditionFromJson(json); + + // bool checkMatchPost(PostModel post) {} +} + +@freezed +class Rule with _$Rule { + const Rule._(); + + @JsonSerializable(explicitToJson: true, includeIfNull: false) + const factory Rule({ + required RuleTrigger trigger, + required RuleCondition? condition, + }) = _Rule; + + factory Rule.fromJson(JsonMap json) => _$RuleFromJson(json); + + static const nullRule = Rule( + trigger: RuleTrigger.postOrCommentEncountered, + condition: null, + ); +} + +enum ContentIndicatorIcon { info, success, warning, error } + +@freezed +class ContentIndicator with _$ContentIndicator { + const factory ContentIndicator({ + ContentIndicatorIcon? icon, + String? text, + int? color, + String? tooltip, + }) = _ContentIndicator; +} + +@freezed +class RuleContentModifier with _$RuleContentModifier { + const factory RuleContentModifier({ + required bool? hide, + required String? title, + required String? body, + required String? replyTemplate, + required String? backgroundHighlight, + required bool? collapse, + required List? indicators, + required List? alternateLinks, + required bool? treatNsfw, + }) = _RuleContentModifier; +} + +RuleContentModifier rulePostOrCommentEncountered( + BuildContext context, + Object postOrComment, +) { + final ac = context.read(); + + final ruleActivations = ac.profile.rules; + + for (var ruleEntry in ac.rules.entries) { + if (ruleActivations[ruleEntry.key] == true) { + final rule = ruleEntry.value; + + if ((post.title != null && rule.hasMatch(post.title!)) || + (post.body != null && rule.hasMatch(post.body!))) { + if (rule.showWithWarning) { + if (!_filterListWarnings.containsKey((post.type, post.id))) { + _filterListWarnings[(post.type, post.id)] = {}; + } + + _filterListWarnings[(post.type, post.id)]!.add(ruleEntry.key); + } else { + return false; + } + } + } + } +} diff --git a/lib/src/models/config_share.dart b/lib/src/models/config_share.dart index 498c01c5..99c7dfa5 100644 --- a/lib/src/models/config_share.dart +++ b/lib/src/models/config_share.dart @@ -7,7 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart'; part 'config_share.freezed.dart'; part 'config_share.g.dart'; -enum ConfigShareType { profile, filterList, feed } +enum ConfigShareType { profile, rule, feed } @freezed class ConfigShare with _$ConfigShare { diff --git a/lib/src/screens/feed/feed_agregator.dart b/lib/src/screens/feed/feed_agregator.dart index 84613f77..482f3213 100644 --- a/lib/src/screens/feed/feed_agregator.dart +++ b/lib/src/screens/feed/feed_agregator.dart @@ -1,10 +1,12 @@ import 'dart:math'; +import 'package:flutter/material.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/controller/feed.dart'; import 'package:interstellar/src/utils/utils.dart'; +import 'package:provider/provider.dart'; import 'feed_screen.dart'; double calcLemmyRanking(PostModel post, DateTime time) { @@ -44,7 +46,8 @@ double calcMbinRanking(PostModel post) { final maxAdvantage = 86400; final maxPenalty = 43200; - final score = (post.boosts ?? 0) + (post.upvotes ?? 0) - (post.downvotes ?? 0); + final score = + (post.boosts ?? 0) + (post.upvotes ?? 0) - (post.downvotes ?? 0); final scoreAdvantage = score * netscoreMultiplier; var commentAdvantage = 0; @@ -204,7 +207,7 @@ class FeedInputState { }); Future<(List, String?)> fetchPage( - AppController ac, + BuildContext context, String pageKey, FeedView view, FeedSort sort, @@ -213,6 +216,8 @@ class FeedInputState { return (_leftover, _nextPage); } + final ac = context.read(); + switch (view) { case FeedView.threads: final postListModel = await ac.api.threads.list( @@ -249,7 +254,7 @@ class FeedInputState { ) : Future.value(); final microblogFuture = - _combinedPage != null && _combinedMicroblogsLeftover.length < 25 + _combinedPage != null && _combinedMicroblogsLeftover.length < 25 ? ac.api.microblogs.list( source, sourceId: sourceId, @@ -264,14 +269,10 @@ class FeedInputState { final postLists = results .map((postListModel) => postListModel?.items ?? []) .toList(); - final merged = merge( - ac.serverSoftware, - [ - [..._combinedThreadsLeftover, ...postLists[0]], - [..._combinedMicroblogsLeftover, ...postLists[1]] - ], - sort, - ); + final merged = merge(ac.serverSoftware, [ + [..._combinedThreadsLeftover, ...postLists[0]], + [..._combinedMicroblogsLeftover, ...postLists[1]], + ], sort); // get next page if new request was sent if (_combinedMicroblogsLeftover.length < 25) { @@ -282,7 +283,9 @@ class FeedInputState { } _combinedThreadsLeftover = merged.$2.isNotEmpty ? merged.$2.first : []; - _combinedMicroblogsLeftover = merged.$2.length > 1 ? merged.$2.last : []; + _combinedMicroblogsLeftover = merged.$2.length > 1 + ? merged.$2.last + : []; ac.logger.i( '$title input fetch($pageKey, $view, $sort) -> (${merged.$1.length}, ${merged.$2.map((i) => i.length).toList()}, $_nextPage, $_combinedPage)', @@ -343,15 +346,17 @@ class FeedAggregator { } Future<(List, String?)> fetchPage( - AppController ac, + BuildContext context, String pageKey, FeedView view, FeedSort sort, ) async { if (inputs.isEmpty) return ([], null); + final ac = context.read(); + final futures = inputs.map( - (input) => input.fetchPage(ac, pageKey, view, sort), + (input) => input.fetchPage(context, pageKey, view, sort), ); final results = await Future.wait(futures); diff --git a/lib/src/screens/feed/feed_screen.dart b/lib/src/screens/feed/feed_screen.dart index dcc1a062..f7aa466b 100644 --- a/lib/src/screens/feed/feed_screen.dart +++ b/lib/src/screens/feed/feed_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/rendering.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/rule.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/community.dart'; import 'package:interstellar/src/models/post.dart'; @@ -62,7 +63,7 @@ class _FeedScreenState extends State bool _isHidden = false; final ExpandableController _drawerController = ExpandableController( - initialExpanded: true + initialExpanded: true, ); NavDrawPersistentState? _navDrawPersistentState; @@ -88,8 +89,11 @@ class _FeedScreenState extends State }; void _initNavExpanded() async { - final initExpanded = (await context.read() - .fetchCachedValue('nav-widescreen')) ?? true; + final initExpanded = + (await context.read().fetchCachedValue( + 'nav-widescreen', + )) ?? + true; if (initExpanded != _drawerController.expanded) { if (!mounted) return; setState(() { @@ -356,17 +360,22 @@ class _FeedScreenState extends State return [ SliverAppBar( - leading: widget.sourceId == null && widget.feed == null - && Breakpoints.isExpanded(context) + leading: + widget.sourceId == null && + widget.feed == null && + Breakpoints.isExpanded(context) ? IconButton( - onPressed: () { - setState(() { - _drawerController.toggle(); - }); - ac.cacheValue('nav-widescreen', _drawerController.expanded); - }, - icon: const Icon(Symbols.menu_rounded) - ) + onPressed: () { + setState(() { + _drawerController.toggle(); + }); + ac.cacheValue( + 'nav-widescreen', + _drawerController.expanded, + ); + }, + icon: const Icon(Symbols.menu_rounded), + ) : null, floating: ac.profile.hideFeedUIOnScroll, pinned: !ac.profile.hideFeedUIOnScroll, @@ -377,10 +386,12 @@ class _FeedScreenState extends State widget.feed != null ? widget.feed!.name : widget.title ?? - context.watch().selectedAccount + - (context.watch().isLoggedIn - ? '' - : ' (${l(context).guest})'), + context + .watch() + .selectedAccount + + (context.watch().isLoggedIn + ? '' + : ' (${l(context).guest})'), softWrap: false, overflow: TextOverflow.fade, ), @@ -615,16 +626,18 @@ class _FeedScreenState extends State drawer: (widget.sourceId != null || widget.feed != null) ? null : NavDrawer( - drawerState: _navDrawPersistentState, - updateState: (NavDrawPersistentState? drawerState) async { - drawerState ??= await fetchNavDrawerState(context.read()); + drawerState: _navDrawPersistentState, + updateState: (NavDrawPersistentState? drawerState) async { + drawerState ??= await fetchNavDrawerState( + context.read(), + ); - if (!mounted) return; - setState(() { - _navDrawPersistentState = drawerState!; - }); - }, - ), + if (!mounted) return; + setState(() { + _navDrawPersistentState = drawerState!; + }); + }, + ), ), ); } @@ -873,9 +886,8 @@ class _FeedScreenBodyState extends State SubordinateScrollController? _scrollController; ScrollDirection _scrollDirection = ScrollDirection.idle; - // Map of postId to FilterList names for posts that match lists that are marked as warnings. - // If a post matches any FilterList that is not shown with warning, then the post is not shown at all. - final Map<(PostType, int), Set> _filterListWarnings = {}; + // Map of post type and id to rule content modifiers. + final Map<(PostType, int), RuleContentModifier> _modifiers = {}; int _lastVisibleIndex = 0; final _markAsReadDebounce = Debouncer(duration: Duration(milliseconds: 500)); @@ -918,10 +930,8 @@ class _FeedScreenBodyState extends State bool get wantKeepAlive => true; Future<(List, String?)> _tryFetchPage(String pageKey) async { - final ac = context.read(); - final page = await _aggregator.fetchPage( - ac, + context, pageKey, widget.view, widget.sort, @@ -932,35 +942,15 @@ class _FeedScreenBodyState extends State // Prevent duplicates final currentItemIds = _pagingController.itemList?.map((post) => (post.type, post.id)) ?? []; - final filterListActivations = ac.profile.filterLists; + final items = newItems .where((post) => !currentItemIds.contains((post.type, post.id))) .where((post) { - // Skip feed filters if it's an explore page - if (widget.sourceId != null) return true; - - for (var filterListEntry in ac.filterLists.entries) { - if (filterListActivations[filterListEntry.key] == true) { - final filterList = filterListEntry.value; + final contentModifier = rulePostOrCommentEncountered(context, post); - if ((post.title != null && filterList.hasMatch(post.title!)) || - (post.body != null && filterList.hasMatch(post.body!))) { - if (filterList.showWithWarning) { - if (!_filterListWarnings.containsKey((post.type, post.id))) { - _filterListWarnings[(post.type, post.id)] = {}; - } - - _filterListWarnings[(post.type, post.id)]!.add( - filterListEntry.key, - ); - } else { - return false; - } - } - } - } + _modifiers[(post.type, post.id)] = contentModifier; - return true; + return contentModifier.hide != true; }) .toList(); @@ -971,7 +961,7 @@ class _FeedScreenBodyState extends State } Future _fetchPage(String pageKey, {bool toEnd = false}) async { - if (pageKey.isEmpty) _filterListWarnings.clear(); + if (pageKey.isEmpty) _modifiers.clear(); try { var (newItems, nextPageKey) = await _tryFetchPage(pageKey); int emptyPageCount = 0; @@ -1196,8 +1186,7 @@ class _FeedScreenBodyState extends State ); }), onTap: onPostTap, - filterListWarnings: - _filterListWarnings[(item.type, item.id)], + modifier: _modifiers[(item.type, item.id)], userCanModerate: widget.userCanModerate, isTopLevel: true, isCompact: true, @@ -1234,8 +1223,7 @@ class _FeedScreenBodyState extends State .comments .create(item.type, item.id, body, lang: lang); }), - filterListWarnings: - _filterListWarnings[(item.type, item.id)], + modifier: _modifiers[(item.type, item.id)], userCanModerate: widget.userCanModerate, isTopLevel: true, ), diff --git a/lib/src/screens/feed/post_item.dart b/lib/src/screens/feed/post_item.dart index ceac0f15..8d34420b 100644 --- a/lib/src/screens/feed/post_item.dart +++ b/lib/src/screens/feed/post_item.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:interstellar/src/api/bookmark.dart'; import 'package:interstellar/src/api/notifications.dart'; import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/rule.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/utils/utils.dart'; @@ -21,7 +22,7 @@ class PostItem extends StatefulWidget { this.onEdit, this.onDelete, this.onTap, - this.filterListWarnings, + this.modifier, this.userCanModerate = false, this.isTopLevel = false, this.isCompact = false, @@ -34,7 +35,7 @@ class PostItem extends StatefulWidget { final Future Function()? onDelete; final void Function()? onTap; final bool isPreview; - final Set? filterListWarnings; + final RuleContentModifier? modifier; final bool userCanModerate; final bool isTopLevel; final bool isCompact; @@ -248,7 +249,7 @@ class _PostItemState extends State { 'edit:${widget.item.type.name}:${ac.instanceHost}:${widget.item.id}', replyDraftResourceId: 'reply:${widget.item.type.name}:${ac.instanceHost}:${widget.item.id}', - filterListWarnings: widget.filterListWarnings, + modifier: widget.modifier, activeBookmarkLists: widget.item.bookmarks, loadPossibleBookmarkLists: whenLoggedIn( context, diff --git a/lib/src/screens/settings/feed_settings_screen.dart b/lib/src/screens/settings/feed_settings_screen.dart index 858d849b..bc3b0247 100644 --- a/lib/src/screens/settings/feed_settings_screen.dart +++ b/lib/src/screens/settings/feed_settings_screen.dart @@ -113,8 +113,8 @@ class _FeedSettingsScreenState extends State { leading: const Icon(Symbols.add_rounded), title: Text(l(context).feeds_new), onTap: () => pushRoute( - context, - builder: (context) => const EditFeedScreen(feed: null), + context, + builder: (context) => const EditFeedScreen(feed: null), ), ), ], @@ -168,14 +168,16 @@ class _EditFeedScreenState extends State { Widget build(BuildContext context) { final ac = context.watch(); return Scaffold( - appBar: AppBar(title: Text(l(context).feeds_edit(widget.feed?? nameController.text))), + appBar: AppBar( + title: Text(l(context).feeds_edit(widget.feed ?? nameController.text)), + ), body: ListView( children: [ Padding( padding: const EdgeInsets.all(16), child: TextEditor( nameController, - label: l(context).filterList_name, + label: l(context).name, onChanged: (_) => setState(() {}), ), ), @@ -221,7 +223,7 @@ class _EditFeedScreenState extends State { removeInput(FeedInput(name: name, sourceType: source)); } }, - ) + ), ), ), Padding( @@ -231,7 +233,7 @@ class _EditFeedScreenState extends State { onPressed: nameController.text.isEmpty || (nameController.text != widget.feed && - ac.filterLists.containsKey(nameController.text)) + ac.feeds.containsKey(nameController.text)) ? null : () async { final name = nameController.text; @@ -289,23 +291,23 @@ void showAddToFeedMenu(BuildContext context, String name, FeedSource source) { final ac = context.read(); ContextMenu( title: l(context).feeds, - items: [...ac.feeds.values - .map( - (feed) => ContextMenuItem( - title: feed.name, - onTap: () async { - final newFeed = feed.copyWith( - inputs: { - ...feed.inputs, - FeedInput(name: name, sourceType: source), - }, - ); - await ac.setFeed(feed.name, newFeed); - if (!context.mounted) return; - Navigator.pop(context); - }, - ), + items: [ + ...ac.feeds.values.map( + (feed) => ContextMenuItem( + title: feed.name, + onTap: () async { + final newFeed = feed.copyWith( + inputs: { + ...feed.inputs, + FeedInput(name: name, sourceType: source), + }, + ); + await ac.setFeed(feed.name, newFeed); + if (!context.mounted) return; + Navigator.pop(context); + }, ), + ), ContextMenuItem( title: l(context).feeds_new, icon: Symbols.add_rounded, diff --git a/lib/src/screens/settings/filter_lists_screen.dart b/lib/src/screens/settings/filter_lists_screen.dart deleted file mode 100644 index dc89eb9c..00000000 --- a/lib/src/screens/settings/filter_lists_screen.dart +++ /dev/null @@ -1,386 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:interstellar/src/controller/controller.dart'; -import 'package:interstellar/src/controller/filter_list.dart'; -import 'package:interstellar/src/models/config_share.dart'; -import 'package:interstellar/src/screens/feed/create_screen.dart'; -import 'package:interstellar/src/screens/settings/about_screen.dart'; -import 'package:interstellar/src/utils/utils.dart'; -import 'package:interstellar/src/widgets/list_tile_select.dart'; -import 'package:interstellar/src/widgets/list_tile_switch.dart'; -import 'package:interstellar/src/widgets/loading_button.dart'; -import 'package:interstellar/src/widgets/selection_menu.dart'; -import 'package:interstellar/src/widgets/text_editor.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; - -class FilterListsScreen extends StatefulWidget { - const FilterListsScreen({super.key}); - - @override - State createState() => _FilterListsScreenState(); -} - -class _FilterListsScreenState extends State { - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - final ac = context.watch(); - - return Scaffold( - appBar: AppBar(title: Text(l(context).filterLists)), - body: ListView( - children: [ - ...ac.filterLists.keys.map( - (name) => Row( - children: [ - Expanded( - child: ListTile( - title: Text(name), - onTap: () => pushRoute( - context, - builder: (context) => - EditFilterListScreen(filterList: name), - ), - trailing: IconButton( - onPressed: () async { - final filterList = context - .read() - .filterLists[name]!; - - final config = await ConfigShare.create( - type: ConfigShareType.filterList, - name: name, - payload: filterList.toJson(), - ); - - if (!context.mounted) return; - String communityName = mbinConfigsCommunityName; - if (communityName.endsWith( - context.read().instanceHost, - )) { - communityName = communityName.split('@').first; - } - - final community = await context - .read() - .api - .community - .getByName(communityName); - - if (!context.mounted) return; - - await pushRoute( - context, - builder: (context) => CreateScreen( - initTitle: '[Filter List] $name', - initBody: - 'Short description here...\n\n${config.toMarkdown()}', - initCommunity: community, - ), - ); - }, - icon: const Icon(Symbols.share_rounded), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Switch( - value: ac.profile.filterLists[name] == true, - onChanged: (value) { - ac.updateProfile( - ac.selectedProfileValue.copyWith( - filterLists: { - ...?ac.selectedProfileValue.filterLists, - name: value, - }, - ), - ); - }, - ), - ), - ], - ), - ), - ListTile( - leading: const Icon(Symbols.add_rounded), - title: Text(l(context).filterList_new), - onTap: () => pushRoute( - context, - builder: (context) => - const EditFilterListScreen(filterList: null), - ), - ), - ], - ), - ); - } -} - -class EditFilterListScreen extends StatefulWidget { - final String? filterList; - final FilterList? importFilterList; - - const EditFilterListScreen({ - required this.filterList, - this.importFilterList, - super.key, - }); - - @override - State createState() => _EditFilterListScreenState(); -} - -class _EditFilterListScreenState extends State { - final nameController = TextEditingController(); - FilterList filterListData = FilterList.nullFilterList; - - @override - void initState() { - super.initState(); - - if (widget.filterList != null) { - nameController.text = widget.filterList!; - - if (widget.importFilterList != null) { - filterListData = widget.importFilterList!; - } else { - filterListData = context - .read() - .filterLists[widget.filterList!]!; - } - } - } - - @override - Widget build(BuildContext context) { - final ac = context.watch(); - - return Scaffold( - appBar: AppBar( - title: Text( - widget.importFilterList != null - ? l(context).filterList_import - : widget.filterList == null - ? l(context).filterList_new - : l(context).filterList_edit, - ), - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - if (widget.filterList != null && widget.importFilterList == null) ...[ - ListTileSwitch( - title: Text(l(context).filterList_activateFilter), - value: ac.profile.filterLists[widget.filterList] == true, - onChanged: (value) { - ac.updateProfile( - ac.selectedProfileValue.copyWith( - filterLists: { - ...?ac.selectedProfileValue.filterLists, - widget.filterList!: value, - }, - ), - ); - }, - ), - const Divider(), - ], - TextEditor( - nameController, - label: l(context).filterList_name, - onChanged: (_) => setState(() {}), - ), - Row( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text(l(context).filterList_phrases), - ), - Flexible( - child: Wrap( - children: [ - ...(filterListData.phrases.map( - (phrase) => Padding( - padding: const EdgeInsets.all(2), - child: InputChip( - label: Text(phrase), - onDeleted: () async { - final newPhrases = filterListData.phrases.toSet(); - - newPhrases.remove(phrase); - - setState(() { - filterListData = filterListData.copyWith( - phrases: newPhrases, - ); - }); - }, - ), - ), - )), - Padding( - padding: const EdgeInsets.all(2), - child: IconButton( - onPressed: () async { - final phraseTextEditingController = - TextEditingController(); - - final phrase = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l(context).filterList_addPhrase), - content: TextEditor(phraseTextEditingController), - actions: [ - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(l(context).cancel), - ), - LoadingFilledButton( - onPressed: () async { - Navigator.of( - context, - ).pop(phraseTextEditingController.text); - }, - label: Text(l(context).filterList_addPhrase), - ), - ], - ), - ); - - if (phrase == null) return; - - final newPhrases = filterListData.phrases.toSet(); - - newPhrases.add(phrase); - - setState(() { - filterListData = filterListData.copyWith( - phrases: newPhrases, - ); - }); - }, - icon: const Icon(Icons.add), - ), - ), - ], - ), - ), - ], - ), - ListTileSwitch( - title: Text(l(context).filterList_showWithContentWarning), - value: filterListData.showWithWarning, - onChanged: (value) => setState(() { - filterListData = filterListData.copyWith(showWithWarning: value); - }), - ), - ListTileSelect( - title: l(context).filterList_matchMode, - selectionMenu: _filterListMatchModeSelect(context), - value: filterListData.matchMode, - oldValue: filterListData.matchMode, - onChange: (newValue) => setState(() { - filterListData = filterListData.copyWith(matchMode: newValue); - }), - ), - ListTileSwitch( - title: Text(l(context).filterList_caseSensitive), - subtitle: Text(l(context).filterList_caseSensitive_help), - value: filterListData.caseSensitive, - onChanged: (value) => setState(() { - filterListData = filterListData.copyWith(caseSensitive: value); - }), - ), - Padding( - padding: const EdgeInsets.only(top: 16), - child: LoadingFilledButton( - icon: const Icon(Symbols.save_rounded), - onPressed: - nameController.text.isEmpty || - ((nameController.text != widget.filterList || - widget.importFilterList != null) && - ac.filterLists.containsKey(nameController.text)) - ? null - : () async { - final name = nameController.text; - - if (widget.filterList == null || - widget.importFilterList != null) { - await ac.setFilterList(name, FilterList.nullFilterList); - } else if (name != widget.filterList) { - await ac.renameFilterList(widget.filterList!, name); - } - - await ac.setFilterList(name, filterListData); - - if (!context.mounted) return; - Navigator.pop(context); - }, - label: Text(l(context).saveChanges), - ), - ), - if (widget.filterList != null && widget.importFilterList == null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: OutlinedButton.icon( - icon: const Icon(Symbols.delete_rounded), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(l(context).filterList_delete), - content: Text(widget.filterList!), - actions: [ - OutlinedButton( - onPressed: () => Navigator.pop(context), - child: Text(l(context).cancel), - ), - FilledButton( - onPressed: () async { - await ac.removeFilterList(widget.filterList!); - - if (!context.mounted) return; - Navigator.pop(context); - Navigator.pop(context); - }, - child: Text(l(context).delete), - ), - ], - ), - ); - }, - label: Text(l(context).filterList_delete), - ), - ), - ], - ), - ); - } -} - -SelectionMenu _filterListMatchModeSelect( - BuildContext context, -) => SelectionMenu(l(context).filterList_matchMode, [ - SelectionMenuItem( - value: FilterListMatchMode.simple, - title: l(context).filterList_matchMode_simple, - subtitle: l(context).filterList_matchMode_simple_help, - ), - SelectionMenuItem( - value: FilterListMatchMode.wholeWords, - title: l(context).filterList_matchMode_wholeWords, - subtitle: l(context).filterList_matchMode_wholeWords_help, - ), - SelectionMenuItem( - value: FilterListMatchMode.regex, - title: l(context).filterList_matchMode_regex, - subtitle: l(context).filterList_matchMode_regex_help, - ), -]); diff --git a/lib/src/screens/settings/profile_selection.dart b/lib/src/screens/settings/profile_selection.dart index a2a629d5..c0842039 100644 --- a/lib/src/screens/settings/profile_selection.dart +++ b/lib/src/screens/settings/profile_selection.dart @@ -262,7 +262,7 @@ class _EditProfileScreenState extends State { children: [ TextEditor( nameController, - label: l(context).profile_name, + label: l(context).name, onChanged: (_) => setState(() {}), ), ListTileSwitch( diff --git a/lib/src/screens/settings/rules_screen.dart b/lib/src/screens/settings/rules_screen.dart new file mode 100644 index 00000000..10e53ece --- /dev/null +++ b/lib/src/screens/settings/rules_screen.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/filter_list.dart'; +import 'package:interstellar/src/controller/rule.dart'; +import 'package:interstellar/src/models/config_share.dart'; +import 'package:interstellar/src/screens/feed/create_screen.dart'; +import 'package:interstellar/src/screens/settings/about_screen.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:interstellar/src/widgets/list_tile_select.dart'; +import 'package:interstellar/src/widgets/list_tile_switch.dart'; +import 'package:interstellar/src/widgets/loading_button.dart'; +import 'package:interstellar/src/widgets/selection_menu.dart'; +import 'package:interstellar/src/widgets/text_editor.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +class RulesScreen extends StatefulWidget { + const RulesScreen({super.key}); + + @override + State createState() => _RulesScreenState(); +} + +class _RulesScreenState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ac = context.watch(); + + return Scaffold( + appBar: AppBar(title: Text(l(context).filterLists)), + body: ListView( + children: [ + ...ac.rules.keys.map( + (name) => Row( + children: [ + Expanded( + child: ListTile( + title: Text(name), + onTap: () => pushRoute( + context, + builder: (context) => EditRuleScreen(rule: name), + ), + trailing: IconButton( + onPressed: () async { + final filterList = context + .read() + .rules[name]!; + + final config = await ConfigShare.create( + type: ConfigShareType.filterList, + name: name, + payload: filterList.toJson(), + ); + + if (!context.mounted) return; + String communityName = mbinConfigsCommunityName; + if (communityName.endsWith( + context.read().instanceHost, + )) { + communityName = communityName.split('@').first; + } + + final community = await context + .read() + .api + .community + .getByName(communityName); + + if (!context.mounted) return; + + await pushRoute( + context, + builder: (context) => CreateScreen( + initTitle: '[Rule] $name', + initBody: + 'Short description here...\n\n${config.toMarkdown()}', + initCommunity: community, + ), + ); + }, + icon: const Icon(Symbols.share_rounded), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Switch( + value: ac.profile.rules[name] == true, + onChanged: (value) { + ac.updateProfile( + ac.selectedProfileValue.copyWith( + rules: { + ...?ac.selectedProfileValue.rules, + name: value, + }, + ), + ); + }, + ), + ), + ], + ), + ), + ListTile( + leading: const Icon(Symbols.add_rounded), + title: Text(l(context).filterList_new), + onTap: () => pushRoute( + context, + builder: (context) => const EditRuleScreen(rule: null), + ), + ), + ], + ), + ); + } +} + +class EditRuleScreen extends StatefulWidget { + final String? rule; + final Rule? importRule; + + const EditRuleScreen({required this.rule, this.importRule, super.key}); + + @override + State createState() => _EditRuleScreenState(); +} + +class _EditRuleScreenState extends State { + final nameController = TextEditingController(); + Rule ruleData = Rule.nullRule; + + @override + void initState() { + super.initState(); + + if (widget.rule != null) { + nameController.text = widget.rule!; + + if (widget.importRule != null) { + ruleData = widget.importRule!; + } else { + ruleData = context.read().rules[widget.rule!]!; + } + } + } + + @override + Widget build(BuildContext context) { + final ac = context.watch(); + + return Scaffold( + appBar: AppBar( + title: Text( + widget.importRule != null + ? l(context).filterList_import + : widget.rule == null + ? l(context).filterList_new + : l(context).filterList_edit, + ), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (widget.rule != null && widget.importRule == null) ...[ + ListTileSwitch( + title: Text(l(context).filterList_activateFilter), + value: ac.profile.rules[widget.rule] == true, + onChanged: (value) { + ac.updateProfile( + ac.selectedProfileValue.copyWith( + rules: { + ...?ac.selectedProfileValue.rules, + widget.rule!: value, + }, + ), + ); + }, + ), + const Divider(), + ], + TextEditor( + nameController, + label: l(context).name, + onChanged: (_) => setState(() {}), + ), + Text('Trigger', style: Theme.of(context).textTheme.titleMedium), + Text('Conditions', style: Theme.of(context).textTheme.titleMedium), + Text('Actions', style: Theme.of(context).textTheme.titleMedium), + Padding( + padding: const EdgeInsets.only(top: 16), + child: LoadingFilledButton( + icon: const Icon(Symbols.save_rounded), + onPressed: + nameController.text.isEmpty || + ((nameController.text != widget.rule || + widget.importRule != null) && + ac.rules.containsKey(nameController.text)) + ? null + : () async { + final name = nameController.text; + + if (widget.rule == null || widget.importRule != null) { + await ac.setRule(name, Rule.nullRule); + } else if (name != widget.rule) { + await ac.renameRule(widget.rule!, name); + } + + await ac.setRule(name, ruleData); + + if (!context.mounted) return; + Navigator.pop(context); + }, + label: Text(l(context).saveChanges), + ), + ), + if (widget.rule != null && widget.importRule == null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: OutlinedButton.icon( + icon: const Icon(Symbols.delete_rounded), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(l(context).filterList_delete), + content: Text(widget.rule!), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(context), + child: Text(l(context).cancel), + ), + FilledButton( + onPressed: () async { + await ac.removeRule(widget.rule!); + + if (!context.mounted) return; + Navigator.pop(context); + Navigator.pop(context); + }, + child: Text(l(context).delete), + ), + ], + ), + ); + }, + label: Text(l(context).filterList_delete), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/settings/settings_screen.dart b/lib/src/screens/settings/settings_screen.dart index 55fc7100..701ae644 100644 --- a/lib/src/screens/settings/settings_screen.dart +++ b/lib/src/screens/settings/settings_screen.dart @@ -8,7 +8,7 @@ import 'package:interstellar/src/screens/settings/data_utilities.dart'; import 'package:interstellar/src/screens/settings/display_screen.dart'; import 'package:interstellar/src/screens/settings/feed_actions_screen.dart'; import 'package:interstellar/src/screens/settings/feed_defaults_screen.dart'; -import 'package:interstellar/src/screens/settings/filter_lists_screen.dart'; +import 'package:interstellar/src/screens/settings/rules_screen.dart'; import 'package:interstellar/src/screens/settings/feed_settings_screen.dart'; import 'package:interstellar/src/screens/settings/notification_screen.dart'; import 'package:interstellar/src/screens/settings/profile_selection.dart'; @@ -72,10 +72,8 @@ class SettingsScreen extends StatelessWidget { ListTile( leading: const Icon(Symbols.filter_1_rounded), title: Text(l(context).filterLists), - onTap: () => pushRoute( - context, - builder: (context) => const FilterListsScreen(), - ), + onTap: () => + pushRoute(context, builder: (context) => const RulesScreen()), ), ListTile( leading: const Icon(Symbols.notifications_rounded), diff --git a/lib/src/widgets/content_item/content_item.dart b/lib/src/widgets/content_item/content_item.dart index 722c2efb..74150c8c 100644 --- a/lib/src/widgets/content_item/content_item.dart +++ b/lib/src/widgets/content_item/content_item.dart @@ -1,5 +1,7 @@ +import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/rule.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/models/notification.dart'; @@ -90,7 +92,7 @@ class ContentItem extends StatefulWidget { final String editDraftResourceId; final String replyDraftResourceId; - final Set? filterListWarnings; + final RuleContentModifier? modifier; final List? activeBookmarkLists; final Future> Function()? loadPossibleBookmarkLists; @@ -158,7 +160,7 @@ class ContentItem extends StatefulWidget { this.onModerateBan, required this.editDraftResourceId, required this.replyDraftResourceId, - this.filterListWarnings, + this.modifier, this.activeBookmarkLists, this.loadPossibleBookmarkLists, this.onAddBookmark, @@ -191,6 +193,12 @@ class _ContentItemState extends State { .defaultCreateLanguage; } + void initReplyController() => setState(() { + _replyTextController = TextEditingController( + text: widget.modifier?.replyTemplate, + ); + }); + @override Widget build(BuildContext context) { return RepaintBoundary( @@ -203,27 +211,19 @@ class _ContentItemState extends State { context, widget, onTranslate: widget.onTranslate, - onReply: widget.onReply != null - ? () => setState(() { - _replyTextController = TextEditingController(); - }) - : () {}, + onReply: widget.onReply != null ? initReplyController : () {}, ), onSecondaryTap: () => showContentMenu( context, widget, onTranslate: widget.onTranslate, - onReply: widget.onReply != null - ? () => setState(() { - _replyTextController = TextEditingController(); - }) - : () {}, + onReply: widget.onReply != null ? initReplyController : () {}, ), child: child, ); }, child: widget.isCompact ? compact() : full(), - ) + ), ); } @@ -366,8 +366,7 @@ class _ContentItemState extends State { return LayoutBuilder( builder: (context, constrains) { final hasWideSize = constrains.maxWidth > 800; - final isRightImage = hasWideSize && - !widget.fullImageSize; + final isRightImage = hasWideSize && !widget.fullImageSize; final double rightImageSize = hasWideSize ? 128 : 64; @@ -437,11 +436,7 @@ class _ContentItemState extends State { _editTextController = TextEditingController(text: widget.body); }), onTranslate: widget.onTranslate, - onReply: widget.onReply != null - ? () => setState(() { - _replyTextController = TextEditingController(); - }) - : () {}, + onReply: widget.onReply != null ? initReplyController : () {}, ); }, ); @@ -461,11 +456,7 @@ class _ContentItemState extends State { : widget.onRemoveBookmark!(); } }, - onReply: widget.onReply != null - ? () => setState(() { - _replyTextController = TextEditingController(); - }) - : () {}, + onReply: widget.onReply != null ? initReplyController : () {}, onMarkAsRead: widget.onMarkAsRead, onModeratePin: widget.onModeratePin, onModerateMarkNSFW: widget.onModerateMarkNSFW, @@ -523,103 +514,7 @@ class _ContentItemState extends State { Expanded( child: Row( children: [ - if (widget.filterListWarnings?.isNotEmpty == - true) - Padding( - padding: const EdgeInsets.only( - right: 10, - ), - child: Tooltip( - message: l(context) - .filterListWarningX( - widget.filterListWarnings!.join( - ', ', - ), - ), - triggerMode: TooltipTriggerMode.tap, - child: const Icon( - Symbols.warning_amber_rounded, - color: Colors.red, - ), - ), - ), - if (widget.isPinned) - Padding( - padding: const EdgeInsets.only( - right: 10, - ), - child: Tooltip( - message: l(context).pinnedInCommunity, - triggerMode: TooltipTriggerMode.tap, - child: const Icon( - Symbols.push_pin_rounded, - size: 20, - ), - ), - ), - if (widget.isNSFW) - Padding( - padding: const EdgeInsets.only( - right: 10, - ), - child: Tooltip( - message: l( - context, - ).notSafeForWork_long, - triggerMode: TooltipTriggerMode.tap, - child: Text( - l(context).notSafeForWork_short, - style: const TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - if (widget.isOC) - Padding( - padding: const EdgeInsets.only( - right: 10, - ), - child: Tooltip( - message: l( - context, - ).originalContent_long, - triggerMode: TooltipTriggerMode.tap, - child: Text( - l(context).originalContent_short, - style: const TextStyle( - color: Colors.lightGreen, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - if (widget.lang != null && - widget.lang != - context - .read() - .profile - .defaultCreateLanguage) - Padding( - padding: const EdgeInsets.only( - right: 10, - ), - child: Tooltip( - message: getLanguageName( - context, - widget.lang!, - ), - triggerMode: TooltipTriggerMode.tap, - child: Text( - widget.lang!, - style: const TextStyle( - color: Colors.purple, - fontWeight: FontWeight.bold, - ), - ), - ), - ), + ...contentIndicators(), if (!widget.showCommunityFirst) ?userWidget, if (widget.showCommunityFirst) ?communityWidget, @@ -1018,11 +913,7 @@ class _ContentItemState extends State { : widget.onRemoveBookmark!(); } }, - onReply: widget.onReply != null - ? () => setState(() { - _replyTextController = TextEditingController(); - }) - : () {}, + onReply: widget.onReply != null ? initReplyController : () {}, onModeratePin: widget.onModeratePin, onModerateMarkNSFW: widget.onModerateMarkNSFW, onModerateDelete: widget.onModerateDelete, @@ -1049,82 +940,7 @@ class _ContentItemState extends State { const SizedBox(height: 4), Row( children: [ - if (widget.filterListWarnings?.isNotEmpty == true) - Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l(context).filterListWarningX( - widget.filterListWarnings!.join(', '), - ), - triggerMode: TooltipTriggerMode.tap, - child: const Icon( - Symbols.warning_amber_rounded, - color: Colors.red, - ), - ), - ), - if (widget.isPinned) - Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l(context).pinnedInCommunity, - triggerMode: TooltipTriggerMode.tap, - child: const Icon( - Symbols.push_pin_rounded, - size: 20, - ), - ), - ), - if (widget.isNSFW) - Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l(context).notSafeForWork_long, - triggerMode: TooltipTriggerMode.tap, - child: Text( - l(context).notSafeForWork_short, - style: const TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - if (widget.isOC) - Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: l(context).originalContent_long, - triggerMode: TooltipTriggerMode.tap, - child: Text( - l(context).originalContent_short, - style: const TextStyle( - color: Colors.lightGreen, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - if (widget.lang != null && - widget.lang != - context - .read() - .profile - .defaultCreateLanguage) - Padding( - padding: const EdgeInsets.only(right: 10), - child: Tooltip( - message: getLanguageName(context, widget.lang!), - triggerMode: TooltipTriggerMode.tap, - child: Text( - widget.lang!, - style: const TextStyle( - color: Colors.purple, - fontWeight: FontWeight.bold, - ), - ), - ), - ), + ...contentIndicators(), if (!widget.showCommunityFirst) ?userWidget, if (widget.showCommunityFirst) ?communityWidget, if (widget.createdAt != null) @@ -1236,4 +1052,95 @@ class _ContentItemState extends State { ), ); } + + List contentIndicators() => [ + if (widget.modifier?.indicators != null) + ...widget.modifier!.indicators!.map( + (indicator) => Padding( + padding: const EdgeInsets.only(right: 10), + child: Tooltip( + message: indicator.tooltip, + triggerMode: TooltipTriggerMode.tap, + child: indicator.icon != null + ? Icon( + switch (indicator.icon!) { + ContentIndicatorIcon.info => Symbols.info_rounded, + ContentIndicatorIcon.success => + Symbols.check_circle_rounded, + ContentIndicatorIcon.warning => Symbols.warning_rounded, + ContentIndicatorIcon.error => Symbols.error_rounded, + }, + color: indicator.color != null + ? Color(indicator.color!) + : null, + ) + : Text( + indicator.text ?? '', + style: TextStyle( + color: indicator.color != null + ? Color(indicator.color!) + : null, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + if (widget.isPinned) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Tooltip( + message: l(context).pinnedInCommunity, + triggerMode: TooltipTriggerMode.tap, + child: const Icon(Symbols.push_pin_rounded, size: 20), + ), + ), + if (widget.isNSFW) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Tooltip( + message: l(context).notSafeForWork_long, + triggerMode: TooltipTriggerMode.tap, + child: Text( + l(context).notSafeForWork_short, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (widget.isOC) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Tooltip( + message: l(context).originalContent_long, + triggerMode: TooltipTriggerMode.tap, + child: Text( + l(context).originalContent_short, + style: const TextStyle( + color: Colors.lightGreen, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (widget.lang != null && + widget.lang != + context.read().profile.defaultCreateLanguage) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Tooltip( + message: getLanguageName(context, widget.lang!), + triggerMode: TooltipTriggerMode.tap, + child: Text( + widget.lang!, + style: const TextStyle( + color: Colors.purple, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ]; } diff --git a/lib/src/widgets/markdown/markdown_config_share.dart b/lib/src/widgets/markdown/markdown_config_share.dart index 8730cd4a..9e2ce2ab 100644 --- a/lib/src/widgets/markdown/markdown_config_share.dart +++ b/lib/src/widgets/markdown/markdown_config_share.dart @@ -3,12 +3,12 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart' as mdf; import 'package:interstellar/src/controller/controller.dart'; -import 'package:interstellar/src/controller/filter_list.dart'; import 'package:interstellar/src/controller/profile.dart'; import 'package:interstellar/src/controller/feed.dart'; +import 'package:interstellar/src/controller/rule.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/settings/feed_settings_screen.dart'; -import 'package:interstellar/src/screens/settings/filter_lists_screen.dart'; +import 'package:interstellar/src/screens/settings/rules_screen.dart'; import 'package:interstellar/src/screens/settings/profile_selection.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/loading_button.dart'; @@ -68,7 +68,7 @@ class _ConfigShareWidgetState extends State { late ConfigShare config; ProfileOptional? configProfile; - FilterList? configFilterList; + Rule? configRule; Feed? configFeed; bool invalid = false; @@ -89,8 +89,8 @@ class _ConfigShareWidgetState extends State { case ConfigShareType.profile: configProfile = ProfileOptional.fromJson(config.payload); break; - case ConfigShareType.filterList: - configFilterList = FilterList.fromJson(config.payload); + case ConfigShareType.rule: + configRule = Rule.fromJson(config.payload); break; case ConfigShareType.feed: configFeed = Feed.fromJson(config.payload); @@ -123,9 +123,7 @@ class _ConfigShareWidgetState extends State { ConfigShareType.profile => l( context, ).configShare_profile_title, - ConfigShareType.filterList => l( - context, - ).configShare_filterList_title, + ConfigShareType.rule => l(context).configShare_rule_title, ConfigShareType.feed => l(context).configShare_feed_title, }), Text( @@ -138,10 +136,9 @@ class _ConfigShareWidgetState extends State { ConfigShareType.profile => l( context, ).configShare_profile_info(config.payload.length), - ConfigShareType.filterList => - l(context).configShare_filterList_info( - configFilterList!.phrases.length, - ), + ConfigShareType.rule => l( + context, + ).configShare_rule_info(configRule!.actions.length), ConfigShareType.feed => l( context, ).configShare_feed_info(configFeed!.inputs.length), @@ -166,28 +163,29 @@ class _ConfigShareWidgetState extends State { ), ); }, - ConfigShareType.filterList => () async { + ConfigShareType.rule => () async { await pushRoute( context, - builder: (context) => EditFilterListScreen( - filterList: config.name, - importFilterList: configFilterList!, + builder: (context) => EditRuleScreen( + rule: config.name, + importRule: configRule!, ), ); }, ConfigShareType.feed => () async { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => EditFeedScreen(feed: config.name, feedData: configFeed), + builder: (context) => EditFeedScreen( + feed: config.name, + feedData: configFeed, + ), ), ); }, }, label: Text(switch (config.type) { ConfigShareType.profile => l(context).profile_import, - ConfigShareType.filterList => l( - context, - ).filterList_import, + ConfigShareType.rule => l(context).rule_import, ConfigShareType.feed => l(context).feeds_import, }), ), diff --git a/lib/src/widgets/markdown/markdown_editor.dart b/lib/src/widgets/markdown/markdown_editor.dart index 13356d3e..de94178a 100644 --- a/lib/src/widgets/markdown/markdown_editor.dart +++ b/lib/src/widgets/markdown/markdown_editor.dart @@ -962,7 +962,7 @@ class _MarkdownEditorConfigShareDialog extends StatefulWidget { class _MarkdownEditorConfigShareDialogState extends State<_MarkdownEditorConfigShareDialog> { List? _profiles; - List? _filterLists; + List? _rules; @override void initState() { @@ -974,7 +974,7 @@ class _MarkdownEditorConfigShareDialogState void loadNames() async { final ac = context.read(); _profiles = await ac.getProfileNames(); - _filterLists = ac.filterLists.keys.toList(); + _rules = ac.rules.keys.toList(); setState(() {}); } @@ -1006,23 +1006,18 @@ class _MarkdownEditorConfigShareDialogState ), ), ], - if (_filterLists != null && _filterLists!.isNotEmpty) ...[ - Padding( - padding: headerEdgeInserts, - child: Text(l(context).filterLists), - ), - ..._filterLists!.map( - (filterListName) => SimpleDialogOption( - child: Text(filterListName), + if (_rules != null && _rules!.isNotEmpty) ...[ + Padding(padding: headerEdgeInserts, child: Text(l(context).rules)), + ..._rules!.map( + (ruleName) => SimpleDialogOption( + child: Text(ruleName), onPressed: () async { - final filterList = context - .read() - .filterLists[filterListName]!; + final rule = context.read().rules[ruleName]!; final config = await ConfigShare.create( - type: ConfigShareType.filterList, - name: filterListName, - payload: filterList.toJson(), + type: ConfigShareType.rule, + name: ruleName, + payload: rule.toJson(), ); final configStr = jsonEncode(config.toJson()); if (!context.mounted) return; diff --git a/pubspec.lock b/pubspec.lock index 93c73fd4..265ac651 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -341,10 +341,10 @@ packages: dependency: "direct main" description: name: flex_color_scheme - sha256: "3344f8f6536c6ce0473b98e9f084ef80ca89024ad3b454f9c32cf840206f4387" + sha256: "034d5720747e6af39b2ad090d82dd92d33fde68e7964f1814b714c9d49ddbd64" url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.3.0" flex_seed_scheme: dependency: transitive description: @@ -665,26 +665,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -1270,10 +1270,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" timezone: dependency: transitive description: @@ -1446,10 +1446,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" visibility_detector: dependency: "direct main" description: @@ -1620,4 +1620,4 @@ packages: version: "2.4.2" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.32.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 95f83404..54754b77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: unifiedpush: ^5.0.2 webpush_encryption: 1.0.0-rc1 flutter_local_notifications: ^19.2.1 - flex_color_scheme: ^8.2.0 + flex_color_scheme: ^8.3.0 share_plus: ^10.1.4 path_provider: ^2.1.5 window_manager: ^0.4.3 From fce88c5de13652c069e71c1a50ab14b02b68e846 Mon Sep 17 00:00:00 2001 From: jwr1 <47087725+jwr1@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:30:22 -0400 Subject: [PATCH 2/6] Work on condition evaluation --- lib/src/controller/rule.dart | 67 ++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/src/controller/rule.dart b/lib/src/controller/rule.dart index e642fafe..26a020b4 100644 --- a/lib/src/controller/rule.dart +++ b/lib/src/controller/rule.dart @@ -250,7 +250,7 @@ class RuleFieldRoot extends RuleField { // } @freezed -class RuleCondition with _$Rule { +class RuleCondition with _$RuleCondition { const RuleCondition._(); @JsonSerializable(explicitToJson: true, includeIfNull: false) @@ -301,19 +301,16 @@ class ContentIndicator with _$ContentIndicator { }) = _ContentIndicator; } -@freezed -class RuleContentModifier with _$RuleContentModifier { - const factory RuleContentModifier({ - required bool? hide, - required String? title, - required String? body, - required String? replyTemplate, - required String? backgroundHighlight, - required bool? collapse, - required List? indicators, - required List? alternateLinks, - required bool? treatNsfw, - }) = _RuleContentModifier; +class RuleContentModifier { + bool? hide; + String? title; + String? body; + String? replyTemplate; + String? backgroundHighlight; + bool? collapse; + List? indicators; + List? alternateLinks; + bool? treatNsfw; } RuleContentModifier rulePostOrCommentEncountered( @@ -322,24 +319,44 @@ RuleContentModifier rulePostOrCommentEncountered( ) { final ac = context.read(); + RuleContentModifier modifier; final ruleActivations = ac.profile.rules; for (var ruleEntry in ac.rules.entries) { if (ruleActivations[ruleEntry.key] == true) { final rule = ruleEntry.value; - if ((post.title != null && rule.hasMatch(post.title!)) || - (post.body != null && rule.hasMatch(post.body!))) { - if (rule.showWithWarning) { - if (!_filterListWarnings.containsKey((post.type, post.id))) { - _filterListWarnings[(post.type, post.id)] = {}; - } - - _filterListWarnings[(post.type, post.id)]!.add(ruleEntry.key); - } else { - return false; - } + if (evaluateCondition(rule.condition)) { + // Trigger here } } } } + +bool evaluateCondition(RuleCondition? condition) { + if (condition == null) return true; + + bool output = false; + + if (condition.and != null) { + for (var subCondition in condition.and!) { + if (!evaluateCondition(subCondition)) { + output = false; + break; + } + } + output = true; + } else if (condition.or != null) { + for (var subCondition in condition.and!) { + if (evaluateCondition(subCondition)) { + output = true; + break; + } + } + output = false; + } else if (condition.field != null) { + final fieldSegments = condition.field!.split('.'); + } + + return condition.not == true ? !output : output; +} From 99ba1b91ae42cc44b6dcd1791bbbae1aa94106d6 Mon Sep 17 00:00:00 2001 From: jwr1 <47087725+jwr1@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:37:30 -0400 Subject: [PATCH 3/6] Work on condition section in rules screen --- lib/src/controller/rule.dart | 252 +++++++++-------- lib/src/screens/settings/rules_screen.dart | 256 +++++++++++++++++- lib/src/screens/settings/settings_screen.dart | 4 +- 3 files changed, 381 insertions(+), 131 deletions(-) diff --git a/lib/src/controller/rule.dart b/lib/src/controller/rule.dart index 26a020b4..78e164c0 100644 --- a/lib/src/controller/rule.dart +++ b/lib/src/controller/rule.dart @@ -18,19 +18,22 @@ enum RuleTrigger { pushNotificationReceived, } -@freezed -class RuleFieldOperator with _$RuleFieldOperator { - const factory RuleFieldOperator({ - required String id, - required String Function(BuildContext context) getName, - required bool Function(T field, T operand) compare, - }) = _RuleFieldOperator; +class RuleFieldOperator { + final String id; + final String Function(BuildContext context) getName; + final bool Function(T field, T operand) compare; + + const RuleFieldOperator({ + required this.id, + required this.getName, + required this.compare, + }); } class RuleField { String id; String Function(BuildContext context) getName; - T Function() getter; + T Function()? getter; /// Set of triggers field is accessible under. Defaults to all. List? triggers; @@ -44,112 +47,126 @@ class RuleField { RuleField({ required this.id, required this.getName, - required this.getter, + this.getter, this.triggers, this.subfields, this.operators, }); + + RuleField? getSubfield(String id) { + if (subfields == null) return null; + + for (var subfield in subfields!) { + if (subfield.id == id) return subfield; + } + + return null; + } + + RuleFieldOperator? getOperator(String id) { + if (operators == null) return null; + + for (var operator in operators!) { + if (operator.id == id) return operator; + } + + return null; + } } class RuleFieldNumber extends RuleField { - RuleFieldNumber({ - required super.id, - required super.getName, - required super.getter, - }) : super( - operators: [ - RuleFieldOperator( - id: 'equalTo', - getName: (context) => 'Equal to', - compare: (field, operand) => field == operand, - ), - RuleFieldOperator( - id: 'lessThan', - getName: (context) => 'Less than', - compare: (field, operand) => field < operand, - ), - RuleFieldOperator( - id: 'lessThanOrEqualTo', - getName: (context) => 'Less than or equal to', - compare: (field, operand) => field <= operand, - ), - RuleFieldOperator( - id: 'greaterThan', - getName: (context) => 'Greater than', - compare: (field, operand) => field > operand, - ), - RuleFieldOperator( - id: 'greaterThanOrEqualTo', - getName: (context) => 'Greater than or equal to', - compare: (field, operand) => field >= operand, - ), - RuleFieldOperator( - id: 'divisibleBy', - getName: (context) => 'Divisible by', - compare: (field, operand) => field % operand == 0, - ), - ], - ); + RuleFieldNumber({required super.id, required super.getName, super.getter}) + : super( + operators: [ + RuleFieldOperator( + id: 'equalTo', + getName: (context) => 'Equal to', + compare: (field, operand) => field == operand, + ), + RuleFieldOperator( + id: 'lessThan', + getName: (context) => 'Less than', + compare: (field, operand) => field < operand, + ), + RuleFieldOperator( + id: 'lessThanOrEqualTo', + getName: (context) => 'Less than or equal to', + compare: (field, operand) => field <= operand, + ), + RuleFieldOperator( + id: 'greaterThan', + getName: (context) => 'Greater than', + compare: (field, operand) => field > operand, + ), + RuleFieldOperator( + id: 'greaterThanOrEqualTo', + getName: (context) => 'Greater than or equal to', + compare: (field, operand) => field >= operand, + ), + RuleFieldOperator( + id: 'divisibleBy', + getName: (context) => 'Divisible by', + compare: (field, operand) => field % operand == 0, + ), + ], + ); } class RuleFieldText extends RuleField { - RuleFieldText({ - required super.id, - required super.getName, - required super.getter, - }) : super( - subfields: [ - RuleFieldNumber( - id: 'charCount', - getName: (context) => 'Character count', - getter: () => getter().length.toDouble(), - ), - RuleFieldNumber( - id: 'wordCount', - getName: (context) => 'Word count', - getter: () => - RegExp(r'[\w-]+').allMatches(getter()).length.toDouble(), - ), - RuleFieldNumber( - id: 'lineCount', - getName: (context) => 'Line count', - getter: () => - getter() - .trim() - .split('\n') - .where((line) => line.trim().isNotEmpty) - .length + - 1, - ), - ], - operators: [ - RuleFieldOperator( - id: 'equalTo', - getName: (context) => 'Equal to', - compare: (field, operand) => field == operand, - ), - RuleFieldOperator( - id: 'contains', - getName: (context) => 'Less than', - compare: (field, operand) => field.contains(operand), - ), - RuleFieldOperator( - id: 'startsWith', - getName: (context) => 'Starts with', - compare: (field, operand) => field.startsWith(operand), - ), - RuleFieldOperator( - id: 'endsWith', - getName: (context) => 'Ends with', - compare: (field, operand) => field.endsWith(operand), - ), - RuleFieldOperator( - id: 'matchesRegex', - getName: (context) => 'Matches RegEx', - compare: (field, operand) => RegExp(operand).hasMatch(field), - ), - ], - ); + RuleFieldText({required super.id, required super.getName, super.getter}) + : super( + subfields: [ + RuleFieldNumber( + id: 'charCount', + getName: (context) => 'Character count', + getter: () => getter!().length.toDouble(), + ), + RuleFieldNumber( + id: 'wordCount', + getName: (context) => 'Word count', + getter: () => + RegExp(r'[\w-]+').allMatches(getter!()).length.toDouble(), + ), + RuleFieldNumber( + id: 'lineCount', + getName: (context) => 'Line count', + getter: () => + getter!() + .trim() + .split('\n') + .where((line) => line.trim().isNotEmpty) + .length + + 1, + ), + ], + operators: [ + RuleFieldOperator( + id: 'equalTo', + getName: (context) => 'Equal to', + compare: (field, operand) => field == operand, + ), + RuleFieldOperator( + id: 'contains', + getName: (context) => 'Less than', + compare: (field, operand) => field.contains(operand), + ), + RuleFieldOperator( + id: 'startsWith', + getName: (context) => 'Starts with', + compare: (field, operand) => field.startsWith(operand), + ), + RuleFieldOperator( + id: 'endsWith', + getName: (context) => 'Ends with', + compare: (field, operand) => field.endsWith(operand), + ), + RuleFieldOperator( + id: 'matchesRegex', + getName: (context) => 'Matches RegEx', + compare: (field, operand) => RegExp(operand).hasMatch(field), + ), + ], + ); } @freezed @@ -161,7 +178,7 @@ class RuleFieldRootContext with _$RuleFieldRootContext { } class RuleFieldRoot extends RuleField { - RuleFieldRoot({required super.getter}) + RuleFieldRoot({super.getter}) : super( id: '', getName: (context) => '', @@ -169,22 +186,22 @@ class RuleFieldRoot extends RuleField { RuleFieldText( id: 'body', getName: (context) => 'Body', - getter: () => getter().post.body ?? '', + getter: () => getter!().post.body ?? '', ), RuleFieldNumber( id: 'upvotes', getName: (context) => 'Upvotes', - getter: () => getter().post.upvotes?.toDouble() ?? 0, + getter: () => getter!().post.upvotes?.toDouble() ?? 0, ), RuleField( id: 'user', getName: (context) => 'User', - getter: () => getter().user, + getter: () => getter!().user, subfields: [ RuleFieldText( id: 'name', getName: (context) => 'Name', - getter: () => getter().user.name, + getter: () => getter!().user.name, ), ], ), @@ -255,15 +272,15 @@ class RuleCondition with _$RuleCondition { @JsonSerializable(explicitToJson: true, includeIfNull: false) const factory RuleCondition({ - required bool? not, + bool? not, - required List? and, + List? and, - required List? or, + List? or, - required String? field, - required String? operator, - required Object? operand, + String? field, + String? operator, + Object? operand, }) = _RuleCondition; factory RuleCondition.fromJson(JsonMap json) => _$RuleConditionFromJson(json); @@ -279,6 +296,7 @@ class Rule with _$Rule { const factory Rule({ required RuleTrigger trigger, required RuleCondition? condition, + required List actions, }) = _Rule; factory Rule.fromJson(JsonMap json) => _$RuleFromJson(json); @@ -286,6 +304,7 @@ class Rule with _$Rule { static const nullRule = Rule( trigger: RuleTrigger.postOrCommentEncountered, condition: null, + actions: [], ); } @@ -319,7 +338,8 @@ RuleContentModifier rulePostOrCommentEncountered( ) { final ac = context.read(); - RuleContentModifier modifier; + RuleContentModifier modifier = RuleContentModifier(); + return modifier; final ruleActivations = ac.profile.rules; for (var ruleEntry in ac.rules.entries) { diff --git a/lib/src/screens/settings/rules_screen.dart b/lib/src/screens/settings/rules_screen.dart index 10e53ece..e29fcb0b 100644 --- a/lib/src/screens/settings/rules_screen.dart +++ b/lib/src/screens/settings/rules_screen.dart @@ -32,7 +32,7 @@ class _RulesScreenState extends State { final ac = context.watch(); return Scaffold( - appBar: AppBar(title: Text(l(context).filterLists)), + appBar: AppBar(title: Text(l(context).rules)), body: ListView( children: [ ...ac.rules.keys.map( @@ -47,14 +47,12 @@ class _RulesScreenState extends State { ), trailing: IconButton( onPressed: () async { - final filterList = context - .read() - .rules[name]!; + final rule = context.read().rules[name]!; final config = await ConfigShare.create( - type: ConfigShareType.filterList, + type: ConfigShareType.rule, name: name, - payload: filterList.toJson(), + payload: rule.toJson(), ); if (!context.mounted) return; @@ -108,7 +106,7 @@ class _RulesScreenState extends State { ), ListTile( leading: const Icon(Symbols.add_rounded), - title: Text(l(context).filterList_new), + title: Text(l(context).rule_new), onTap: () => pushRoute( context, builder: (context) => const EditRuleScreen(rule: null), @@ -153,14 +151,16 @@ class _EditRuleScreenState extends State { Widget build(BuildContext context) { final ac = context.watch(); + print(ruleData.toJson()); + return Scaffold( appBar: AppBar( title: Text( widget.importRule != null - ? l(context).filterList_import + ? l(context).rule_import : widget.rule == null - ? l(context).filterList_new - : l(context).filterList_edit, + ? l(context).rule_new + : l(context).rule_edit, ), ), body: ListView( @@ -168,7 +168,7 @@ class _EditRuleScreenState extends State { children: [ if (widget.rule != null && widget.importRule == null) ...[ ListTileSwitch( - title: Text(l(context).filterList_activateFilter), + title: Text(l(context).rule_activate), value: ac.profile.rules[widget.rule] == true, onChanged: (value) { ac.updateProfile( @@ -190,6 +190,55 @@ class _EditRuleScreenState extends State { ), Text('Trigger', style: Theme.of(context).textTheme.titleMedium), Text('Conditions', style: Theme.of(context).textTheme.titleMedium), + ruleData.condition == null + ? Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Add a condition that needs to be satisfied for the rule to run.', + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton( + onPressed: () { + setState(() { + ruleData = ruleData.copyWith( + condition: RuleCondition(and: []), + ); + }); + }, + child: Text('AND'), + ), + OutlinedButton( + onPressed: () { + setState(() { + ruleData = ruleData.copyWith( + condition: RuleCondition(or: []), + ); + }); + }, + child: Text('OR'), + ), + OutlinedButton( + onPressed: () { + setState(() { + ruleData = ruleData.copyWith( + condition: RuleCondition(field: ''), + ); + }); + }, + child: Text('FIELD'), + ), + ], + ), + ], + ) + : RuleConditionItem(ruleData.condition!, (newValue) { + setState(() { + ruleData = ruleData.copyWith(condition: newValue); + }); + }), Text('Actions', style: Theme.of(context).textTheme.titleMedium), Padding( padding: const EdgeInsets.only(top: 16), @@ -227,7 +276,7 @@ class _EditRuleScreenState extends State { showDialog( context: context, builder: (BuildContext context) => AlertDialog( - title: Text(l(context).filterList_delete), + title: Text(l(context).rule_delete), content: Text(widget.rule!), actions: [ OutlinedButton( @@ -248,7 +297,7 @@ class _EditRuleScreenState extends State { ), ); }, - label: Text(l(context).filterList_delete), + label: Text(l(context).rule_delete), ), ), ], @@ -256,3 +305,184 @@ class _EditRuleScreenState extends State { ); } } + +class RuleConditionItem extends StatelessWidget { + final RuleCondition condition; + final void Function(RuleCondition? newValue) onChange; + + const RuleConditionItem(this.condition, this.onChange, {super.key}); + + @override + Widget build(BuildContext context) { + final fieldSegments = (condition.field ?? '').split('.'); + + List widgets = []; + + RuleField currentField = RuleFieldRoot(); + + for (var i = 0; i < fieldSegments.length; i++) { + if (currentField.subfields != null) { + widgets.add( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + isSelected: condition.not == true, + color: condition.not == true ? Colors.red : null, + onPressed: () { + onChange( + condition.copyWith( + not: condition.not == true ? null : true, + ), + ); + }, + icon: Icon(Symbols.swap_horiz_rounded), + tooltip: condition.not == true ? 'Uninvert' : 'Invert', + ), + IconButton( + onPressed: () { + onChange(null); + }, + icon: Icon(Symbols.close_rounded), + tooltip: 'Remove', + ), + DropdownMenu( + initialSelection: condition.and != null + ? 'AND' + : condition.or != null + ? 'OR' + : fieldSegments[i], + onSelected: (newValue) { + if (newValue == 'AND') { + onChange( + RuleCondition( + and: condition.and ?? condition.or ?? [], + not: condition.not, + ), + ); + } else if (newValue == 'OR') { + onChange( + RuleCondition( + or: condition.or ?? condition.and ?? [], + not: condition.not, + ), + ); + } else { + onChange( + RuleCondition( + field: [...fieldSegments.take(i), newValue].join('.'), + not: condition.not, + ), + ); + } + }, + dropdownMenuEntries: [ + if (i == 0) ...[ + DropdownMenuEntry(value: 'AND', label: 'AND'), + DropdownMenuEntry(value: 'OR', label: 'OR'), + ], + ...currentField.subfields!.map( + (subfield) => DropdownMenuEntry( + value: subfield.id, + label: subfield.getName(context), + ), + ), + ], + ), + ], + ), + ); + + // final nextField = currentField.getSubfield(fieldId); + // if (nextField == null) break; + // currentField = nextField; + } + } + + if (condition.and != null || condition.or != null) { + final isAnd = condition.and != null; + final subConditions = (isAnd ? condition.and : condition.or)!; + + widgets.add( + Container( + margin: EdgeInsets.only(left: 8), + padding: EdgeInsets.only(left: 8), + decoration: BoxDecoration( + border: Border(left: BorderSide(color: Colors.grey)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...subConditions + .asMap() + .map( + (i, subCondition) => MapEntry( + i, + RuleConditionItem(subCondition, (newValue) { + final newList = [...subConditions]; + if (newValue != null) { + newList[i] = newValue; + } else { + newList.removeAt(i); + } + onChange( + isAnd + ? RuleCondition(and: newList, not: condition.not) + : RuleCondition(or: newList, not: condition.not), + ); + }), + ), + ) + .values, + Row( + children: [ + OutlinedButton( + onPressed: () { + final newList = [...subConditions]; + newList.add(RuleCondition(and: [])); + onChange( + isAnd + ? RuleCondition(and: newList, not: condition.not) + : RuleCondition(or: newList, not: condition.not), + ); + }, + child: Text('AND'), + ), + OutlinedButton( + onPressed: () { + final newList = [...subConditions]; + newList.add(RuleCondition(or: [])); + onChange( + isAnd + ? RuleCondition(and: newList, not: condition.not) + : RuleCondition(or: newList, not: condition.not), + ); + }, + child: Text('OR'), + ), + OutlinedButton( + onPressed: () { + final newList = [...subConditions]; + newList.add(RuleCondition(field: '')); + onChange( + isAnd + ? RuleCondition(and: newList, not: condition.not) + : RuleCondition(or: newList, not: condition.not), + ); + }, + child: Text('FIELD'), + ), + ], + ), + ], + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ); + } +} diff --git a/lib/src/screens/settings/settings_screen.dart b/lib/src/screens/settings/settings_screen.dart index 701ae644..e1c8555d 100644 --- a/lib/src/screens/settings/settings_screen.dart +++ b/lib/src/screens/settings/settings_screen.dart @@ -70,8 +70,8 @@ class SettingsScreen extends StatelessWidget { ), ), ListTile( - leading: const Icon(Symbols.filter_1_rounded), - title: Text(l(context).filterLists), + leading: const Icon(Symbols.search_gear_rounded), + title: Text(l(context).rules), onTap: () => pushRoute(context, builder: (context) => const RulesScreen()), ), From 0481e7337ae1879aa8b977a3f804e540d2e3efcb Mon Sep 17 00:00:00 2001 From: jwr1 <47087725+jwr1@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:39:04 -0400 Subject: [PATCH 4/6] Add recursive dropdown fields to rule conditions --- lib/src/controller/rule.dart | 4 +- lib/src/screens/settings/rules_screen.dart | 303 +++++++++++---------- 2 files changed, 162 insertions(+), 145 deletions(-) diff --git a/lib/src/controller/rule.dart b/lib/src/controller/rule.dart index 78e164c0..437aa60e 100644 --- a/lib/src/controller/rule.dart +++ b/lib/src/controller/rule.dart @@ -53,8 +53,8 @@ class RuleField { this.operators, }); - RuleField? getSubfield(String id) { - if (subfields == null) return null; + RuleField? getSubfield(String? id) { + if (id == null || subfields == null) return null; for (var subfield in subfields!) { if (subfield.id == id) return subfield; diff --git a/lib/src/screens/settings/rules_screen.dart b/lib/src/screens/settings/rules_screen.dart index e29fcb0b..4a819f07 100644 --- a/lib/src/screens/settings/rules_screen.dart +++ b/lib/src/screens/settings/rules_screen.dart @@ -314,175 +314,192 @@ class RuleConditionItem extends StatelessWidget { @override Widget build(BuildContext context) { - final fieldSegments = (condition.field ?? '').split('.'); + final fieldSegments = (condition.field ?? '').isEmpty + ? [] + : condition.field!.split('.'); - List widgets = []; + List dropdownWidgets = []; - RuleField currentField = RuleFieldRoot(); + RuleField? currentField = RuleFieldRoot(); + int fieldSegmentIndex = 0; + + while (currentField != null) { + print(fieldSegmentIndex); + print(fieldSegments.take(fieldSegmentIndex).toList()); + print(currentField.subfields); - for (var i = 0; i < fieldSegments.length; i++) { if (currentField.subfields != null) { - widgets.add( - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - isSelected: condition.not == true, - color: condition.not == true ? Colors.red : null, - onPressed: () { - onChange( - condition.copyWith( - not: condition.not == true ? null : true, - ), - ); - }, - icon: Icon(Symbols.swap_horiz_rounded), - tooltip: condition.not == true ? 'Uninvert' : 'Invert', - ), - IconButton( - onPressed: () { - onChange(null); - }, - icon: Icon(Symbols.close_rounded), - tooltip: 'Remove', - ), - DropdownMenu( - initialSelection: condition.and != null - ? 'AND' - : condition.or != null - ? 'OR' - : fieldSegments[i], - onSelected: (newValue) { - if (newValue == 'AND') { - onChange( - RuleCondition( - and: condition.and ?? condition.or ?? [], - not: condition.not, - ), - ); - } else if (newValue == 'OR') { - onChange( - RuleCondition( - or: condition.or ?? condition.and ?? [], - not: condition.not, - ), - ); - } else { - onChange( - RuleCondition( - field: [...fieldSegments.take(i), newValue].join('.'), - not: condition.not, - ), - ); - } - }, - dropdownMenuEntries: [ - if (i == 0) ...[ - DropdownMenuEntry(value: 'AND', label: 'AND'), - DropdownMenuEntry(value: 'OR', label: 'OR'), - ], - ...currentField.subfields!.map( - (subfield) => DropdownMenuEntry( - value: subfield.id, - label: subfield.getName(context), - ), + final parentFieldSegments = fieldSegments.take(fieldSegmentIndex); + + dropdownWidgets.add( + DropdownMenu( + initialSelection: condition.and != null + ? 'AND' + : condition.or != null + ? 'OR' + : fieldSegments.elementAtOrNull(fieldSegmentIndex), + onSelected: (newValue) { + if (newValue == 'AND') { + onChange( + RuleCondition( + and: condition.and ?? condition.or ?? [], + not: condition.not, + ), + ); + } else if (newValue == 'OR') { + onChange( + RuleCondition( + or: condition.or ?? condition.and ?? [], + not: condition.not, + ), + ); + } else { + onChange( + RuleCondition( + field: [...parentFieldSegments, newValue].join('.'), + not: condition.not, ), - ], + ); + } + }, + dropdownMenuEntries: [ + if (fieldSegmentIndex == 0) ...[ + DropdownMenuEntry(value: 'AND', label: 'AND'), + DropdownMenuEntry(value: 'OR', label: 'OR'), + ], + ...currentField.subfields!.map( + (subfield) => DropdownMenuEntry( + value: subfield.id, + label: subfield.getName(context), + ), ), ], ), ); - - // final nextField = currentField.getSubfield(fieldId); - // if (nextField == null) break; - // currentField = nextField; } + + currentField = currentField.getSubfield( + fieldSegments.elementAtOrNull(fieldSegmentIndex), + ); + fieldSegmentIndex++; } + final mainWidget = Row( + children: [ + IconButton( + isSelected: condition.not == true, + color: condition.not == true ? Colors.red : null, + onPressed: () { + onChange( + condition.copyWith(not: condition.not == true ? null : true), + ); + }, + icon: Icon(Symbols.swap_horiz_rounded), + tooltip: condition.not == true ? 'Uninvert' : 'Invert', + ), + IconButton( + onPressed: () { + onChange(null); + }, + icon: Icon(Symbols.close_rounded), + tooltip: 'Remove', + ), + ...dropdownWidgets, + ], + ); + if (condition.and != null || condition.or != null) { final isAnd = condition.and != null; final subConditions = (isAnd ? condition.and : condition.or)!; - widgets.add( - Container( - margin: EdgeInsets.only(left: 8), - padding: EdgeInsets.only(left: 8), - decoration: BoxDecoration( - border: Border(left: BorderSide(color: Colors.grey)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...subConditions - .asMap() - .map( - (i, subCondition) => MapEntry( - i, - RuleConditionItem(subCondition, (newValue) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + mainWidget, + Container( + margin: EdgeInsets.only(left: 8), + padding: EdgeInsets.only(left: 8), + decoration: BoxDecoration( + border: Border(left: BorderSide(color: Colors.grey)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...subConditions + .asMap() + .map( + (i, subCondition) => MapEntry( + i, + RuleConditionItem(subCondition, (newValue) { + final newList = [...subConditions]; + if (newValue != null) { + newList[i] = newValue; + } else { + newList.removeAt(i); + } + onChange( + isAnd + ? RuleCondition( + and: newList, + not: condition.not, + ) + : RuleCondition( + or: newList, + not: condition.not, + ), + ); + }), + ), + ) + .values, + Row( + children: [ + OutlinedButton( + onPressed: () { final newList = [...subConditions]; - if (newValue != null) { - newList[i] = newValue; - } else { - newList.removeAt(i); - } + newList.add(RuleCondition(and: [])); onChange( isAnd ? RuleCondition(and: newList, not: condition.not) : RuleCondition(or: newList, not: condition.not), ); - }), + }, + child: Text('AND'), ), - ) - .values, - Row( - children: [ - OutlinedButton( - onPressed: () { - final newList = [...subConditions]; - newList.add(RuleCondition(and: [])); - onChange( - isAnd - ? RuleCondition(and: newList, not: condition.not) - : RuleCondition(or: newList, not: condition.not), - ); - }, - child: Text('AND'), - ), - OutlinedButton( - onPressed: () { - final newList = [...subConditions]; - newList.add(RuleCondition(or: [])); - onChange( - isAnd - ? RuleCondition(and: newList, not: condition.not) - : RuleCondition(or: newList, not: condition.not), - ); - }, - child: Text('OR'), - ), - OutlinedButton( - onPressed: () { - final newList = [...subConditions]; - newList.add(RuleCondition(field: '')); - onChange( - isAnd - ? RuleCondition(and: newList, not: condition.not) - : RuleCondition(or: newList, not: condition.not), - ); - }, - child: Text('FIELD'), - ), - ], - ), - ], + OutlinedButton( + onPressed: () { + final newList = [...subConditions]; + newList.add(RuleCondition(or: [])); + onChange( + isAnd + ? RuleCondition(and: newList, not: condition.not) + : RuleCondition(or: newList, not: condition.not), + ); + }, + child: Text('OR'), + ), + OutlinedButton( + onPressed: () { + final newList = [...subConditions]; + newList.add(RuleCondition(field: '')); + onChange( + isAnd + ? RuleCondition(and: newList, not: condition.not) + : RuleCondition(or: newList, not: condition.not), + ); + }, + child: Text('FIELD'), + ), + ], + ), + ], + ), ), - ), + ], ); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widgets, - ); + return mainWidget; } } From 87d2c992a4c7816b4fd888ccb014db68f86a73a8 Mon Sep 17 00:00:00 2001 From: jwr1 <47087725+jwr1@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:32:15 -0400 Subject: [PATCH 5/6] Add operator and value fields --- lib/src/controller/rule.dart | 26 +-- lib/src/screens/settings/rules_screen.dart | 208 +++++++++++++-------- 2 files changed, 148 insertions(+), 86 deletions(-) diff --git a/lib/src/controller/rule.dart b/lib/src/controller/rule.dart index 437aa60e..01a7b616 100644 --- a/lib/src/controller/rule.dart +++ b/lib/src/controller/rule.dart @@ -80,32 +80,32 @@ class RuleFieldNumber extends RuleField { operators: [ RuleFieldOperator( id: 'equalTo', - getName: (context) => 'Equal to', + getName: (context) => 'equal to', compare: (field, operand) => field == operand, ), RuleFieldOperator( id: 'lessThan', - getName: (context) => 'Less than', + getName: (context) => 'less than', compare: (field, operand) => field < operand, ), RuleFieldOperator( id: 'lessThanOrEqualTo', - getName: (context) => 'Less than or equal to', + getName: (context) => 'less than or equal to', compare: (field, operand) => field <= operand, ), RuleFieldOperator( id: 'greaterThan', - getName: (context) => 'Greater than', + getName: (context) => 'greater than', compare: (field, operand) => field > operand, ), RuleFieldOperator( id: 'greaterThanOrEqualTo', - getName: (context) => 'Greater than or equal to', + getName: (context) => 'greater than or equal to', compare: (field, operand) => field >= operand, ), RuleFieldOperator( id: 'divisibleBy', - getName: (context) => 'Divisible by', + getName: (context) => 'divisible by', compare: (field, operand) => field % operand == 0, ), ], @@ -142,27 +142,27 @@ class RuleFieldText extends RuleField { operators: [ RuleFieldOperator( id: 'equalTo', - getName: (context) => 'Equal to', + getName: (context) => 'equal to', compare: (field, operand) => field == operand, ), RuleFieldOperator( id: 'contains', - getName: (context) => 'Less than', + getName: (context) => 'contains', compare: (field, operand) => field.contains(operand), ), RuleFieldOperator( id: 'startsWith', - getName: (context) => 'Starts with', + getName: (context) => 'starts with', compare: (field, operand) => field.startsWith(operand), ), RuleFieldOperator( id: 'endsWith', - getName: (context) => 'Ends with', + getName: (context) => 'ends with', compare: (field, operand) => field.endsWith(operand), ), RuleFieldOperator( id: 'matchesRegex', - getName: (context) => 'Matches RegEx', + getName: (context) => 'matches regex', compare: (field, operand) => RegExp(operand).hasMatch(field), ), ], @@ -272,7 +272,7 @@ class RuleCondition with _$RuleCondition { @JsonSerializable(explicitToJson: true, includeIfNull: false) const factory RuleCondition({ - bool? not, + bool? invert, List? and, @@ -378,5 +378,5 @@ bool evaluateCondition(RuleCondition? condition) { final fieldSegments = condition.field!.split('.'); } - return condition.not == true ? !output : output; + return condition.invert == true ? !output : output; } diff --git a/lib/src/screens/settings/rules_screen.dart b/lib/src/screens/settings/rules_screen.dart index 4a819f07..a7b6b762 100644 --- a/lib/src/screens/settings/rules_screen.dart +++ b/lib/src/screens/settings/rules_screen.dart @@ -1,15 +1,12 @@ import 'package:flutter/material.dart'; import 'package:interstellar/src/controller/controller.dart'; -import 'package:interstellar/src/controller/filter_list.dart'; import 'package:interstellar/src/controller/rule.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/feed/create_screen.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; import 'package:interstellar/src/utils/utils.dart'; -import 'package:interstellar/src/widgets/list_tile_select.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; import 'package:interstellar/src/widgets/loading_button.dart'; -import 'package:interstellar/src/widgets/selection_menu.dart'; import 'package:interstellar/src/widgets/text_editor.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -200,7 +197,7 @@ class _EditRuleScreenState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - OutlinedButton( + FilledButton( onPressed: () { setState(() { ruleData = ruleData.copyWith( @@ -208,9 +205,9 @@ class _EditRuleScreenState extends State { ); }); }, - child: Text('AND'), + child: Text('All of'), ), - OutlinedButton( + FilledButton( onPressed: () { setState(() { ruleData = ruleData.copyWith( @@ -218,7 +215,7 @@ class _EditRuleScreenState extends State { ); }); }, - child: Text('OR'), + child: Text('Any of'), ), OutlinedButton( onPressed: () { @@ -228,7 +225,7 @@ class _EditRuleScreenState extends State { ); }); }, - child: Text('FIELD'), + child: Text('Single field'), ), ], ), @@ -320,50 +317,46 @@ class RuleConditionItem extends StatelessWidget { List dropdownWidgets = []; - RuleField? currentField = RuleFieldRoot(); - int fieldSegmentIndex = 0; - - while (currentField != null) { - print(fieldSegmentIndex); - print(fieldSegments.take(fieldSegmentIndex).toList()); - print(currentField.subfields); + RuleField currentField = RuleFieldRoot(); + for (var i = 0; i < fieldSegments.length + 1; i++) { if (currentField.subfields != null) { - final parentFieldSegments = fieldSegments.take(fieldSegmentIndex); + final parentFieldSegments = fieldSegments.take(i); dropdownWidgets.add( DropdownMenu( + label: i == 0 ? Text('Field') : null, initialSelection: condition.and != null ? 'AND' : condition.or != null ? 'OR' - : fieldSegments.elementAtOrNull(fieldSegmentIndex), + : fieldSegments.elementAtOrNull(i), onSelected: (newValue) { if (newValue == 'AND') { onChange( RuleCondition( and: condition.and ?? condition.or ?? [], - not: condition.not, + invert: condition.invert, ), ); } else if (newValue == 'OR') { onChange( RuleCondition( or: condition.or ?? condition.and ?? [], - not: condition.not, + invert: condition.invert, ), ); } else { onChange( RuleCondition( field: [...parentFieldSegments, newValue].join('.'), - not: condition.not, + invert: condition.invert, ), ); } }, dropdownMenuEntries: [ - if (fieldSegmentIndex == 0) ...[ + if (i == 0) ...[ DropdownMenuEntry(value: 'AND', label: 'AND'), DropdownMenuEntry(value: 'OR', label: 'OR'), ], @@ -376,26 +369,28 @@ class RuleConditionItem extends StatelessWidget { ], ), ); - } - currentField = currentField.getSubfield( - fieldSegments.elementAtOrNull(fieldSegmentIndex), - ); - fieldSegmentIndex++; + final nextField = currentField.getSubfield( + fieldSegments.elementAtOrNull(i), + ); + if (nextField != null) currentField = nextField; + } } final mainWidget = Row( children: [ IconButton( - isSelected: condition.not == true, - color: condition.not == true ? Colors.red : null, + isSelected: condition.invert == true, + color: condition.invert == true ? Colors.red : null, onPressed: () { onChange( - condition.copyWith(not: condition.not == true ? null : true), + condition.copyWith( + invert: condition.invert == true ? null : true, + ), ); }, icon: Icon(Symbols.swap_horiz_rounded), - tooltip: condition.not == true ? 'Uninvert' : 'Invert', + tooltip: condition.invert == true ? 'Uninvert' : 'Invert', ), IconButton( onPressed: () { @@ -405,6 +400,92 @@ class RuleConditionItem extends StatelessWidget { tooltip: 'Remove', ), ...dropdownWidgets, + if (currentField.operators != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownMenu( + label: Text('Operator'), + initialSelection: condition.operator, + onSelected: (newValue) { + onChange( + RuleCondition( + field: condition.field, + invert: condition.invert, + operator: newValue, + ), + ); + }, + dropdownMenuEntries: [ + ...currentField.operators!.map( + (operator) => DropdownMenuEntry( + value: operator.id, + label: operator.getName(context), + ), + ), + ], + ), + ), + switch (currentField) { + RuleField() => SizedBox( + width: 200, + child: TextFormField( + initialValue: condition.operand is String + ? condition.operand as String + : '', + onChanged: (newValue) { + onChange( + RuleCondition( + field: condition.field, + invert: condition.invert, + operator: condition.operator, + operand: newValue, + ), + ); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text('Value'), + ), + ), + ), + RuleField() => SizedBox( + width: 200, + child: TextFormField( + initialValue: condition.operand is double + ? (condition.operand as double).toString() + : '0', + onChanged: (newValue) { + final parsedValue = double.tryParse(newValue); + + if (parsedValue != null) { + onChange( + RuleCondition( + field: condition.field, + invert: condition.invert, + operator: condition.operator, + operand: parsedValue, + ), + ); + } + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text('Value'), + ), + keyboardType: TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + validator: (value) => + value == null || double.tryParse(value) != null + ? null + : 'Invalid number', + autovalidateMode: AutovalidateMode.always, + ), + ), + _ => Text('INVALID OPERAND TYPE'), + }, + ], ], ); @@ -418,8 +499,8 @@ class RuleConditionItem extends StatelessWidget { children: [ mainWidget, Container( - margin: EdgeInsets.only(left: 8), - padding: EdgeInsets.only(left: 8), + margin: EdgeInsets.only(left: 12, bottom: 8), + padding: EdgeInsets.only(left: 12), decoration: BoxDecoration( border: Border(left: BorderSide(color: Colors.grey)), ), @@ -442,56 +523,37 @@ class RuleConditionItem extends StatelessWidget { isAnd ? RuleCondition( and: newList, - not: condition.not, + invert: condition.invert, ) : RuleCondition( or: newList, - not: condition.not, + invert: condition.invert, ), ); }), ), ) .values, - Row( - children: [ - OutlinedButton( - onPressed: () { - final newList = [...subConditions]; - newList.add(RuleCondition(and: [])); - onChange( - isAnd - ? RuleCondition(and: newList, not: condition.not) - : RuleCondition(or: newList, not: condition.not), - ); - }, - child: Text('AND'), - ), - OutlinedButton( - onPressed: () { - final newList = [...subConditions]; - newList.add(RuleCondition(or: [])); - onChange( - isAnd - ? RuleCondition(and: newList, not: condition.not) - : RuleCondition(or: newList, not: condition.not), - ); - }, - child: Text('OR'), - ), - OutlinedButton( - onPressed: () { - final newList = [...subConditions]; - newList.add(RuleCondition(field: '')); - onChange( - isAnd - ? RuleCondition(and: newList, not: condition.not) - : RuleCondition(or: newList, not: condition.not), - ); - }, - child: Text('FIELD'), - ), - ], + OutlinedButton.icon( + onPressed: () { + final newList = [ + ...subConditions, + RuleCondition(field: ''), + ]; + onChange( + isAnd + ? RuleCondition( + and: newList, + invert: condition.invert, + ) + : RuleCondition( + or: newList, + invert: condition.invert, + ), + ); + }, + label: Text('New field'), + icon: Icon(Symbols.add_rounded), ), ], ), From b15112f8a01ace83a0121c7718166c868245c0a7 Mon Sep 17 00:00:00 2001 From: jwr1 <47087725+jwr1@users.noreply.github.com> Date: Wed, 10 Sep 2025 19:26:40 -0400 Subject: [PATCH 6/6] Add padding --- lib/src/screens/settings/rules_screen.dart | 222 +++++++++++---------- 1 file changed, 114 insertions(+), 108 deletions(-) diff --git a/lib/src/screens/settings/rules_screen.dart b/lib/src/screens/settings/rules_screen.dart index a7b6b762..ff01ae1d 100644 --- a/lib/src/screens/settings/rules_screen.dart +++ b/lib/src/screens/settings/rules_screen.dart @@ -377,116 +377,119 @@ class RuleConditionItem extends StatelessWidget { } } - final mainWidget = Row( - children: [ - IconButton( - isSelected: condition.invert == true, - color: condition.invert == true ? Colors.red : null, - onPressed: () { - onChange( - condition.copyWith( - invert: condition.invert == true ? null : true, - ), - ); - }, - icon: Icon(Symbols.swap_horiz_rounded), - tooltip: condition.invert == true ? 'Uninvert' : 'Invert', - ), - IconButton( - onPressed: () { - onChange(null); - }, - icon: Icon(Symbols.close_rounded), - tooltip: 'Remove', - ), - ...dropdownWidgets, - if (currentField.operators != null) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: DropdownMenu( - label: Text('Operator'), - initialSelection: condition.operator, - onSelected: (newValue) { - onChange( - RuleCondition( - field: condition.field, - invert: condition.invert, - operator: newValue, - ), - ); - }, - dropdownMenuEntries: [ - ...currentField.operators!.map( - (operator) => DropdownMenuEntry( - value: operator.id, - label: operator.getName(context), - ), + final mainWidget = Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + IconButton( + isSelected: condition.invert == true, + color: condition.invert == true ? Colors.red : null, + onPressed: () { + onChange( + condition.copyWith( + invert: condition.invert == true ? null : true, ), - ], - ), + ); + }, + icon: Icon(Symbols.swap_horiz_rounded), + tooltip: condition.invert == true ? 'Uninvert' : 'Invert', + ), + IconButton( + onPressed: () { + onChange(null); + }, + icon: Icon(Symbols.close_rounded), + tooltip: 'Remove', ), - switch (currentField) { - RuleField() => SizedBox( - width: 200, - child: TextFormField( - initialValue: condition.operand is String - ? condition.operand as String - : '', - onChanged: (newValue) { + ...dropdownWidgets, + if (currentField.operators != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownMenu( + label: Text('Operator'), + initialSelection: condition.operator, + onSelected: (newValue) { onChange( RuleCondition( field: condition.field, invert: condition.invert, - operator: condition.operator, - operand: newValue, + operator: newValue, ), ); }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - label: Text('Value'), - ), + dropdownMenuEntries: [ + ...currentField.operators!.map( + (operator) => DropdownMenuEntry( + value: operator.id, + label: operator.getName(context), + ), + ), + ], ), ), - RuleField() => SizedBox( - width: 200, - child: TextFormField( - initialValue: condition.operand is double - ? (condition.operand as double).toString() - : '0', - onChanged: (newValue) { - final parsedValue = double.tryParse(newValue); - - if (parsedValue != null) { + switch (currentField) { + RuleField() => SizedBox( + width: 200, + child: TextFormField( + initialValue: condition.operand is String + ? condition.operand as String + : '', + onChanged: (newValue) { onChange( RuleCondition( field: condition.field, invert: condition.invert, operator: condition.operator, - operand: parsedValue, + operand: newValue, ), ); - } - }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - label: Text('Value'), + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text('Value'), + ), ), - keyboardType: TextInputType.numberWithOptions( - decimal: true, - signed: true, + ), + RuleField() => SizedBox( + width: 200, + child: TextFormField( + initialValue: condition.operand is double + ? (condition.operand as double).toString() + : '0', + onChanged: (newValue) { + final parsedValue = double.tryParse(newValue); + + if (parsedValue != null) { + onChange( + RuleCondition( + field: condition.field, + invert: condition.invert, + operator: condition.operator, + operand: parsedValue, + ), + ); + } + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text('Value'), + ), + keyboardType: TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + validator: (value) => + value == null || double.tryParse(value) != null + ? null + : 'Invalid number', + autovalidateMode: AutovalidateMode.always, ), - validator: (value) => - value == null || double.tryParse(value) != null - ? null - : 'Invalid number', - autovalidateMode: AutovalidateMode.always, ), - ), - _ => Text('INVALID OPERAND TYPE'), - }, + _ => Text('INVALID OPERAND TYPE'), + }, + ], ], - ], + ), ); if (condition.and != null || condition.or != null) { @@ -534,26 +537,29 @@ class RuleConditionItem extends StatelessWidget { ), ) .values, - OutlinedButton.icon( - onPressed: () { - final newList = [ - ...subConditions, - RuleCondition(field: ''), - ]; - onChange( - isAnd - ? RuleCondition( - and: newList, - invert: condition.invert, - ) - : RuleCondition( - or: newList, - invert: condition.invert, - ), - ); - }, - label: Text('New field'), - icon: Icon(Symbols.add_rounded), + Padding( + padding: const EdgeInsets.only(top: 8), + child: OutlinedButton.icon( + onPressed: () { + final newList = [ + ...subConditions, + RuleCondition(field: ''), + ]; + onChange( + isAnd + ? RuleCondition( + and: newList, + invert: condition.invert, + ) + : RuleCondition( + or: newList, + invert: condition.invert, + ), + ); + }, + label: Text('New field'), + icon: Icon(Symbols.add_rounded), + ), ), ], ),