add push rule settings

Fixes #126
This commit is contained in:
Henry Hiles 2026-10-31 15:22:13 -04:00
commit c9172e92eb
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
11 changed files with 596 additions and 15 deletions

View file

@ -12,6 +12,7 @@
"prefs", "prefs",
"recents", "recents",
"unban", "unban",
"underride",
"unifiedpush", "unifiedpush",
"unredact", "unredact",
"webpush" "webpush"

View file

@ -6,6 +6,8 @@ import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/push_rules.dart";
import "package:nexus/models/requests/update_push_rule.dart";
class AccountDataController extends Notifier<AccountData> { class AccountDataController extends Notifier<AccountData> {
@override @override
@ -24,24 +26,15 @@ class AccountDataController extends Notifier<AccountData> {
} }
}); });
Future<void> setEnabledImagePacks(ImagePackRooms packs) async { Future<void> setEnabledImagePacks(ImagePackRooms packs) {
ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries
.expand((entry) => entry.value.keys.map((key) => (entry.key, key))) .expand((entry) => entry.value.keys.map((key) => (entry.key, key)))
.toISet(); .toISet();
final keys = keysOf(packs); final keys = keysOf(packs);
bool arrived(AccountData data) {
final current = keysOf(data.enabledImagePacks);
return current.length == keys.length && current.containsAll(keys);
}
final completer = Completer<void>();
final removeListener = listenSelf((_, data) {
if (arrived(data) && !completer.isCompleted) completer.complete();
});
try {
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
await Future.wait([
return _sendAndWait(
() => .wait([
if (state.imagePackRooms != null) if (state.imagePackRooms != null)
client.setAccountData( client.setAccountData(
.new(type: AccountData.imagePackRoomsKey, content: packs), .new(type: AccountData.imagePackRoomsKey, content: packs),
@ -49,8 +42,65 @@ class AccountDataController extends Notifier<AccountData> {
client.setAccountData( client.setAccountData(
.new(type: AccountData.legacyImagePackRoomsKey, content: packs), .new(type: AccountData.legacyImagePackRoomsKey, content: packs),
), ),
]); ]),
(data) {
final current = keysOf(data.enabledImagePacks);
return current.length == keys.length && current.containsAll(keys);
},
);
}
/// Saves [rule] of [kind], creating it if it's new, or otherwise changing only
/// its actions and whether it's enabled.
Future<void> savePushRule(PushRuleKind kind, PushRule rule) {
UpdatePushRuleRequest request(UpdatePushRuleAction action) =>
.new(kind: kind, ruleId: rule.ruleId, action: action);
return _updatePushRules(
.new(switch (state.pushRules.global.find(kind, rule.ruleId)) {
null => [request(.put).copyWith(newContent: rule)],
final previous => [
if (previous.actions != rule.actions)
request(.putActions).copyWith(actions: rule.actions),
if (previous.enabled != rule.enabled)
request(rule.enabled ? .enable : .disable),
],
}),
(rules) => rules.find(kind, rule.ruleId) == rule,
);
}
Future<void> deletePushRule(PushRuleKind kind, String ruleId) =>
_updatePushRules(
.new([.new(kind: kind, ruleId: ruleId, action: .delete)]),
(rules) => rules.find(kind, ruleId) == null,
);
/// Sends [requests] in order, then waits until [arrived] is true for the
/// synced push rules.
Future<void> _updatePushRules(
IList<UpdatePushRuleRequest> requests,
bool Function(PushRuleset rules) arrived,
) => _sendAndWait(
() => .forEach(
requests,
ref.read(ClientController.provider.notifier).updatePushRule,
),
(data) => arrived(data.pushRules.global),
);
/// Runs [send], then waits until [arrived] is true for the synced data.
Future<void> _sendAndWait(
Future<void> Function() send,
bool Function(AccountData data) arrived,
) async {
final completer = Completer<void>();
final removeListener = listenSelf((_, data) {
if (arrived(data) && !completer.isCompleted) completer.complete();
});
try {
await send();
if (!arrived(state)) { if (!arrived(state)) {
await completer.future.timeout( await completer.future.timeout(
const .new(seconds: 10), const .new(seconds: 10),

View file

@ -49,6 +49,7 @@ import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/requests/set_account_data.dart"; import "package:nexus/models/requests/set_account_data.dart";
import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/update_push_rule.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_summary.dart"; import "package:nexus/models/room_summary.dart";
@ -333,6 +334,9 @@ class ClientController extends AsyncNotifier<int> {
Future<void> setAccountData(SetAccountDataRequest request) => Future<void> setAccountData(SetAccountDataRequest request) =>
_sendCommand("set_account_data", request.toJson()); _sendCommand("set_account_data", request.toJson());
Future<void> updatePushRule(UpdatePushRuleRequest request) =>
_sendCommand("update_push_rule", request.toJson());
Future<void> registerPusher(RegisterPusherRequest request) => Future<void> registerPusher(RegisterPusherRequest request) =>
_sendCommand("register_homeserver_push", request.toJson()); _sendCommand("register_homeserver_push", request.toJson());

View file

@ -6,6 +6,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.dart"; import "package:m3e_buttons/m3e_buttons.dart";
import "package:multi_state_switch/multi_state_switch.dart";
import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
@ -25,15 +26,18 @@ import "package:nexus/models/account_data.dart";
import "package:nexus/models/client_state.dart"; import "package:nexus/models/client_state.dart";
import "package:nexus/models/image_pack.dart"; import "package:nexus/models/image_pack.dart";
import "package:nexus/models/name_color.dart"; import "package:nexus/models/name_color.dart";
import "package:nexus/models/push_rule_switch.dart";
import "package:nexus/models/room_order.dart"; import "package:nexus/models/room_order.dart";
import "package:nexus/models/setting.dart"; import "package:nexus/models/setting.dart";
import "package:nexus/models/settings.dart"; import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart"; import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/image_pack_avatar.dart"; import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart"; import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
import "package:nexus/widgets/settings/keyword_field.dart";
import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -95,6 +99,67 @@ class SettingsSectionsController
.onError(showError), .onError(showError),
); );
final pushRules = ref.watch(
AccountDataController.provider.select((data) => data.pushRules.global),
);
final accountData = ref.read(AccountDataController.provider.notifier);
Setting header(String title, String description, IconData icon) => .new(
title: title,
description: description,
icon: icon,
builder: (title, _, _) => Padding(
padding: .symmetric(horizontal: 16),
child: DividerText(title),
),
);
/// A switch for push rules, which is disabled while [onChanged] saves. A
/// null [value] is shown in the middle, for rules set up some other way.
Setting pushSwitchSetting({
required String title,
required String description,
required IconData icon,
required bool? value,
required Future<void> Function(bool value) onChanged,
}) => .new(
title: title,
description: description,
icon: icon,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
final position = switch (value) {
false => 0,
null => 1,
true => 2,
};
void change(bool value) =>
saving.run(() => onChanged(value)).onError(showError);
return ListTile(
enabled: !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
trailing: MultiStateSwitch(
states: 3,
// As wide as a regular switch, which has 2 states 20 apart
stepWidth: 10,
value: position,
semanticFormatterCallback: (position) =>
["Off", "Custom", "On"][position],
onChanged: saving.active
? null
: (newPosition) => change(newPosition > position),
),
// Tapping turns it off only when it's fully on
onTap: () => change(value != true),
);
},
),
);
return .new({ return .new({
"General": .new([ "General": .new([
.new( .new(
@ -412,7 +477,7 @@ class SettingsSectionsController
icon: Icons.notifications, icon: Icons.notifications,
settings: .new([ settings: .new([
.new( .new(
title: "Push notifications", title: "Push Notifications",
description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.", description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.",
builder: (title, description, icon) => HookConsumer( builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) { builder: (context, ref, _) {
@ -509,6 +574,102 @@ class SettingsSectionsController
), ),
icon: Icons.notification_add, icon: Icons.notification_add,
), ),
if (pushRules.master case final master?)
pushSwitchSetting(
title: "Mute All",
description: "Don't notify for anything, without changing your other rules.",
icon: Icons.notifications_off,
value: master.enabled,
onChanged: (mute) => accountData.savePushRule(
.override,
master.copyWith(enabled: mute),
),
),
...PushRuleSection.values.expand(
(section) => [
header(section.title, section.description, section.icon),
...PushRuleSwitch.values
.where(
(option) =>
option.section == section &&
option.rulesIn(pushRules).isNotEmpty,
)
.map(
(option) => pushSwitchSetting(
title: option.title,
description: option.description,
icon: option.icon,
value: option.valueIn(pushRules),
onChanged: (value) => Future.wait(
option
.changesIn(pushRules, value)
.map(
(change) => accountData.savePushRule(
change.kind,
change.rule,
),
),
),
),
),
],
),
header(
"Keywords",
"Messages containing these words, which notify with a highlight and sound.",
Icons.text_fields,
),
...pushRules.keywords.map(
(rule) => Setting(
title: rule.pattern ?? rule.ruleId,
description: "Messages containing this keyword.",
icon: Icons.text_fields,
builder: (title, description, icon) => HookBuilder(
key: ValueKey(rule.ruleId),
builder: (context) {
final saving = Saving.use();
final custom =
!rule.enabled || rule.actions != .highlightWithSound;
return ListTile(
enabled: !saving.active,
leading: Icon(icon),
title: Text(title),
trailing: IconButton(
tooltip: custom
? "Set up differently by another client. Press to reset to Nexus defaults."
: "Remove keyword",
icon: Icon(custom ? Icons.warning : Icons.delete),
onPressed: saving.active
? null
: () => saving
.run(
() => custom
? accountData.savePushRule(
.content,
rule.copyWith(
enabled: true,
actions: .highlightWithSound,
),
)
: accountData.deletePushRule(
.content,
rule.ruleId,
),
)
.onError(showError),
),
);
},
),
),
),
.new(
title: "Add Keyword",
description: "Notify for messages containing a word.",
icon: Icons.add,
builder: (_, _, _) => KeywordField(),
),
]), ]),
), ),
.new( .new(

View file

@ -1,5 +1,6 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/push_rules.dart";
part "account_data.freezed.dart"; part "account_data.freezed.dart";
part "account_data.g.dart"; part "account_data.g.dart";
@ -22,12 +23,15 @@ class const AccountData({
final ImagePackRooms? imagePackRooms, final ImagePackRooms? imagePackRooms,
@JsonKey(name: AccountData.legacyImagePackRoomsKey) @JsonKey(name: AccountData.legacyImagePackRoomsKey)
final ImagePackRooms? legacyImagePackRooms, final ImagePackRooms? legacyImagePackRooms,
@JsonKey(name: AccountData.pushRulesKey)
final PushRules pushRules = const .new(),
}) with _$AccountData { }) with _$AccountData {
static const invitePermissionConfigKey = "m.invite_permission_config"; static const invitePermissionConfigKey = "m.invite_permission_config";
static const directKey = "m.direct"; static const directKey = "m.direct";
static const recentEmojiKey = "m.recent_emoji"; static const recentEmojiKey = "m.recent_emoji";
static const imagePackRoomsKey = "m.image_pack.rooms"; static const imagePackRoomsKey = "m.image_pack.rooms";
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
static const pushRulesKey = "m.push_rules";
ImagePackRooms get enabledImagePacks => ImagePackRooms get enabledImagePacks =>
imagePackRooms ?? legacyImagePackRooms ?? const .new(); imagePackRooms ?? legacyImagePackRooms ?? const .new();

View file

@ -0,0 +1,150 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/push_rules.dart";
enum PushRuleSection(
final String title,
final String description,
final IconData icon,
) {
messages("Messages", "Which messages notify you.", Icons.chat),
otherEvents("Other Events", "Events other than messages.", Icons.more_horiz),
}
/// Default push rules turned on or off together, as some match the same events
/// in different ways, like encrypted ones, which the server can't read.
enum PushRuleSwitch(
final PushRuleSection section,
final String title,
final String description,
final IconData icon,
/// What the rules do when on.
final PushActions actions,
/// Whether turning it off disables the rules, so the next rule that matches
/// decides, rather than not notifying.
final bool disableWhenOff,
) {
mentions(
.messages,
"Mentions",
"Messages that mention you, or contain your name.",
Icons.alternate_email,
.highlightWithSound,
true,
),
roomMentions(
.messages,
"Room Mentions",
"Messages that notify everyone in the room with @room.",
Icons.campaign,
.highlightWithSound,
true,
),
directMessages(
.messages,
"Direct Messages",
"Messages in rooms with just you and one other person.",
Icons.person,
.notifyWithSound,
false,
),
groupMessages(
.messages,
"Group Messages",
"Messages in rooms with more people, rather than only mentions and keywords.",
Icons.group,
.notifyWithSound,
false,
),
invites(
.otherEvents,
"Invites",
"Invites for you to join rooms.",
Icons.mail,
.notifyWithSound,
false,
),
calls(
.otherEvents,
"Calls",
"Incoming calls.",
Icons.call,
.new(notify: true, sound: "ring"),
false,
),
tombstones(
.otherEvents,
"Room Tombstones",
"Rooms being upgraded or locked.",
Icons.lock,
.notifyWithSound,
false,
);
IList<({PushRuleKind kind, String ruleId})> get rules => .new(switch (this) {
.mentions => [
(kind: .override, ruleId: ".m.rule.is_user_mention"),
(kind: .override, ruleId: ".m.rule.contains_display_name"),
(kind: .content, ruleId: ".m.rule.contains_user_name"),
],
.roomMentions => [
(kind: .override, ruleId: ".m.rule.is_room_mention"),
(kind: .override, ruleId: ".m.rule.roomnotif"),
],
.directMessages => [
(kind: .underride, ruleId: ".m.rule.room_one_to_one"),
(kind: .underride, ruleId: ".m.rule.encrypted_room_one_to_one"),
],
.groupMessages => [
(kind: .underride, ruleId: ".m.rule.message"),
(kind: .underride, ruleId: ".m.rule.encrypted"),
],
.invites => [(kind: .override, ruleId: ".m.rule.invite_for_me")],
.calls => [(kind: .underride, ruleId: ".m.rule.call")],
.tombstones => [(kind: .override, ruleId: ".m.rule.tombstone")],
});
/// The rules in [ruleset], as servers may not have them all.
IList<({PushRuleKind kind, PushRule rule})> rulesIn(PushRuleset ruleset) =>
rules
.map(
(rule) => switch (ruleset.find(rule.kind, rule.ruleId)) {
final found? => (kind: rule.kind, rule: found),
null => null,
},
)
.nonNulls
.toIList();
/// Whether the rules in [ruleset] are on or off, or null if they're set up
/// some other way.
bool? valueIn(PushRuleset ruleset) => switch (rulesIn(ruleset)
.map((entry) => entry.rule)) {
final rules
when rules.every((rule) => rule.enabled && rule.actions == actions) =>
true,
final rules
when rules.every((rule) => !rule.enabled || !rule.actions.notify) =>
false,
_ => null,
};
/// The rules in [ruleset], turned on or off.
IList<({PushRuleKind kind, PushRule rule})> changesIn(
PushRuleset ruleset,
bool value,
) => rulesIn(ruleset)
.map(
(entry) => (
kind: entry.kind,
rule: value
? entry.rule.copyWith(enabled: true, actions: actions)
: disableWhenOff
? entry.rule.copyWith(enabled: false)
: entry.rule.copyWith(enabled: true, actions: const .new()),
),
)
.toIList();
}

105
lib/models/push_rules.dart Normal file
View file

@ -0,0 +1,105 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "push_rules.freezed.dart";
part "push_rules.g.dart";
/// The content of the m.push_rules account data.
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PushRules({final PushRuleset global = const .new()})
with _$PushRules {
Map<String, Object?> toJson() => _$PushRulesToJson(this);
factory PushRules.fromJson(Map<String, Object?> json) =>
_$PushRulesFromJson(json);
}
/// Push rules by kind, apart from room and sender rules, which Nexus doesn't
/// use. The first rule that matches decides what happens.
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PushRuleset({
final IList<PushRule> override = const .empty(),
final IList<PushRule> content = const .empty(),
final IList<PushRule> underride = const .empty(),
}) with _$PushRuleset {
IList<PushRule> ofKind(PushRuleKind kind) => switch (kind) {
.override => override,
.content => content,
.underride => underride,
};
PushRule? find(PushRuleKind kind, String ruleId) =>
ofKind(kind).where((rule) => rule.ruleId == ruleId).firstOrNull;
/// Mutes everything when enabled, as it matches every event.
PushRule? get master => find(.override, ".m.rule.master");
/// The content rules the user added, which match these words.
IList<PushRule> get keywords =>
content.where((rule) => !rule.isDefault).toIList();
Map<String, Object?> toJson() => _$PushRulesetToJson(this);
factory PushRuleset.fromJson(Map<String, Object?> json) =>
_$PushRulesetFromJson(json);
}
enum PushRuleKind { override, content, underride }
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PushRule({
required final String ruleId,
final PushActions actions = const .new(),
/// Whether the server made the rule, so it can't be deleted.
@JsonKey(name: "default") final bool isDefault = false,
final bool enabled = true,
/// What content rules match in messages' bodies.
final String? pattern,
}) with _$PushRule {
Map<String, Object?> toJson() => _$PushRuleToJson(this);
factory PushRule.fromJson(Map<String, Object?> json) =>
_$PushRuleFromJson(json);
}
/// What happens when a push rule matches. Actions Nexus doesn't support are
/// dropped, so they're replaced when these are changed.
@Freezed(toJson: false, fromJson: false)
class const PushActions({
final bool notify = false,
final bool highlight = false,
final String? sound,
}) with _$PushActions {
static const defaultSound = "default";
static const notifyWithSound = PushActions(notify: true, sound: defaultSound);
static const highlightWithSound = PushActions(
notify: true,
highlight: true,
sound: defaultSound,
);
List<Object?> toJson() => [
if (notify) "notify",
if (highlight) {"set_tweak": "highlight"},
if (sound != null) {"set_tweak": "sound", "value": sound},
];
factory PushActions.fromJson(List<Object?> json) => json.fold(
const .new(),
(actions, action) => switch (action) {
"notify" => actions.copyWith(notify: true),
{"set_tweak": "highlight", "value": final bool highlight} =>
actions.copyWith(highlight: highlight),
{"set_tweak": "highlight"} => actions.copyWith(highlight: true),
{"set_tweak": "sound", "value": final String sound} => actions.copyWith(
sound: sound,
),
_ => actions,
},
);
}

View file

@ -0,0 +1,27 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/push_rules.dart";
part "update_push_rule.freezed.dart";
part "update_push_rule.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const UpdatePushRuleRequest({
required final PushRuleKind kind,
required final String ruleId,
required final UpdatePushRuleAction action,
/// The new rule, when putting it. Only its actions and pattern are used.
final PushRule? newContent,
/// The new actions, when putting only them.
final PushActions? actions,
}) with _$UpdatePushRuleRequest {
Map<String, Object?> toJson() => _$UpdatePushRuleRequestToJson(this);
factory UpdatePushRuleRequest.fromJson(Map<String, Object?> json) =>
_$UpdatePushRuleRequestFromJson(json);
}
@JsonEnum(fieldRename: .snake)
enum UpdatePushRuleAction { enable, disable, put, putActions, delete }

View file

@ -0,0 +1,70 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
/// Adds a keyword, which notifies with a highlight and sound for messages
/// containing it.
class const KeywordField({super.key}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final saving = Saving.use();
final controller = useTextEditingController();
final keyword = useValueListenable(controller).text.trim();
final exists = ref.watch(
AccountDataController.provider.select(
(data) => data.pushRules.global.find(.content, keyword) != null,
),
);
// Keywords are their rules' IDs, which can't be like these
final error = keyword.startsWith(".")
? "Can't start with a ."
: keyword.contains(RegExp(r"[/\\]"))
? r"Can't contain / or \"
: exists
? "Already a keyword"
: null;
final canAdd = keyword.isNotEmpty && error == null && !saving.active;
void add() {
if (!canAdd) return;
saving
.run(() async {
await ref
.read(AccountDataController.provider.notifier)
.savePushRule(
.content,
.new(
ruleId: keyword,
pattern: keyword,
actions: .highlightWithSound,
),
);
if (context.mounted) controller.clear();
})
.onError(showError);
}
return Padding(
padding: .symmetric(horizontal: 16),
child: TextField(
controller: controller,
enabled: !saving.active,
onSubmitted: (_) => add(),
decoration: .new(
labelText: "New keyword",
helperText: "Matches whole words. * matches anything, and ? matches one character.",
errorText: error,
suffixIcon: IconButton(
tooltip: "Add keyword",
icon: Icon(Icons.add),
onPressed: canAdd ? add : null,
),
),
),
);
}
}

View file

@ -1032,6 +1032,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
multi_state_switch:
dependency: "direct main"
description:
name: multi_state_switch
sha256: "501890f46d33d6cba6a84eb8f1689dba180a264ce3373fa689f75f4887d822fd"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
native_toolchain_c: native_toolchain_c:
dependency: transitive dependency: transitive
description: description:

View file

@ -107,6 +107,7 @@ dependencies:
app_links: 7.2.2 app_links: 7.2.2
file_selector: 1.1.0 file_selector: 1.1.0
material_ui: 1.6.0 material_ui: 1.6.0
multi_state_switch: 1.1.0
unifiedpush: 6.2.0 unifiedpush: 6.2.0
unifiedpush_storage_shared_preferences: 1.0.0 unifiedpush_storage_shared_preferences: 1.0.0
flutter_local_notifications: 22.3.1 flutter_local_notifications: 22.3.1