From c9172e92ebee9222e856485d077d58747b5a60bc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 31 Oct 2026 15:22:13 -0400 Subject: [PATCH] add push rule settings Fixes #126 --- .vscode/settings.json | 1 + lib/controllers/account_data.dart | 78 +++++++++-- lib/controllers/client.dart | 4 + lib/controllers/settings_sections.dart | 163 +++++++++++++++++++++- lib/models/account_data.dart | 4 + lib/models/push_rule_switch.dart | 150 ++++++++++++++++++++ lib/models/push_rules.dart | 105 ++++++++++++++ lib/models/requests/update_push_rule.dart | 27 ++++ lib/widgets/settings/keyword_field.dart | 70 ++++++++++ pubspec.lock | 8 ++ pubspec.yaml | 1 + 11 files changed, 596 insertions(+), 15 deletions(-) create mode 100644 lib/models/push_rule_switch.dart create mode 100644 lib/models/push_rules.dart create mode 100644 lib/models/requests/update_push_rule.dart create mode 100644 lib/widgets/settings/keyword_field.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index a5cac8a..2d6d7e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ "prefs", "recents", "unban", + "underride", "unifiedpush", "unredact", "webpush" diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index d98e4ca..3bf5acb 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -6,6 +6,8 @@ import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.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 { @override @@ -24,24 +26,15 @@ class AccountDataController extends Notifier { } }); - Future setEnabledImagePacks(ImagePackRooms packs) async { + Future setEnabledImagePacks(ImagePackRooms packs) { ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries .expand((entry) => entry.value.keys.map((key) => (entry.key, key))) .toISet(); final keys = keysOf(packs); - bool arrived(AccountData data) { - final current = keysOf(data.enabledImagePacks); - return current.length == keys.length && current.containsAll(keys); - } + final client = ref.read(ClientController.provider.notifier); - final completer = Completer(); - final removeListener = listenSelf((_, data) { - if (arrived(data) && !completer.isCompleted) completer.complete(); - }); - - try { - final client = ref.read(ClientController.provider.notifier); - await Future.wait([ + return _sendAndWait( + () => .wait([ if (state.imagePackRooms != null) client.setAccountData( .new(type: AccountData.imagePackRoomsKey, content: packs), @@ -49,8 +42,65 @@ class AccountDataController extends Notifier { client.setAccountData( .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 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 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 _updatePushRules( + IList 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 _sendAndWait( + Future Function() send, + bool Function(AccountData data) arrived, + ) async { + final completer = Completer(); + final removeListener = listenSelf((_, data) { + if (arrived(data) && !completer.isCompleted) completer.complete(); + }); + + try { + await send(); if (!arrived(state)) { await completer.future.timeout( const .new(seconds: 10), diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index f158069..d225da8 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -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_membership.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/room.dart"; import "package:nexus/models/room_summary.dart"; @@ -333,6 +334,9 @@ class ClientController extends AsyncNotifier { Future setAccountData(SetAccountDataRequest request) => _sendCommand("set_account_data", request.toJson()); + Future updatePushRule(UpdatePushRuleRequest request) => + _sendCommand("update_push_rule", request.toJson()); + Future registerPusher(RegisterPusherRequest request) => _sendCommand("register_homeserver_push", request.toJson()); diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index b74f50d..915b1fb 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -6,6 +6,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:intl/intl.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/client.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/image_pack.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/setting.dart"; import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/models/show_and_send.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/settings/dialog_list_tile.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/reset_encryption_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -95,6 +99,67 @@ class SettingsSectionsController .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 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({ "General": .new([ .new( @@ -412,7 +477,7 @@ class SettingsSectionsController icon: Icons.notifications, settings: .new([ .new( - title: "Push notifications", + title: "Push Notifications", description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.", builder: (title, description, icon) => HookConsumer( builder: (context, ref, _) { @@ -509,6 +574,102 @@ class SettingsSectionsController ), 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( diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 88da5a0..1a7949b 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -1,5 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/push_rules.dart"; part "account_data.freezed.dart"; part "account_data.g.dart"; @@ -22,12 +23,15 @@ class const AccountData({ final ImagePackRooms? imagePackRooms, @JsonKey(name: AccountData.legacyImagePackRoomsKey) final ImagePackRooms? legacyImagePackRooms, + @JsonKey(name: AccountData.pushRulesKey) + final PushRules pushRules = const .new(), }) with _$AccountData { static const invitePermissionConfigKey = "m.invite_permission_config"; static const directKey = "m.direct"; static const recentEmojiKey = "m.recent_emoji"; static const imagePackRoomsKey = "m.image_pack.rooms"; static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; + static const pushRulesKey = "m.push_rules"; ImagePackRooms get enabledImagePacks => imagePackRooms ?? legacyImagePackRooms ?? const .new(); diff --git a/lib/models/push_rule_switch.dart b/lib/models/push_rule_switch.dart new file mode 100644 index 0000000..1c385d2 --- /dev/null +++ b/lib/models/push_rule_switch.dart @@ -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(); +} diff --git a/lib/models/push_rules.dart b/lib/models/push_rules.dart new file mode 100644 index 0000000..a23d12b --- /dev/null +++ b/lib/models/push_rules.dart @@ -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 toJson() => _$PushRulesToJson(this); + + factory PushRules.fromJson(Map 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 override = const .empty(), + final IList content = const .empty(), + final IList underride = const .empty(), +}) with _$PushRuleset { + IList 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 get keywords => + content.where((rule) => !rule.isDefault).toIList(); + + Map toJson() => _$PushRulesetToJson(this); + + factory PushRuleset.fromJson(Map 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 toJson() => _$PushRuleToJson(this); + + factory PushRule.fromJson(Map 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 toJson() => [ + if (notify) "notify", + if (highlight) {"set_tweak": "highlight"}, + if (sound != null) {"set_tweak": "sound", "value": sound}, + ]; + + factory PushActions.fromJson(List 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, + }, + ); +} diff --git a/lib/models/requests/update_push_rule.dart b/lib/models/requests/update_push_rule.dart new file mode 100644 index 0000000..3a4fde3 --- /dev/null +++ b/lib/models/requests/update_push_rule.dart @@ -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 toJson() => _$UpdatePushRuleRequestToJson(this); + + factory UpdatePushRuleRequest.fromJson(Map json) => + _$UpdatePushRuleRequestFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum UpdatePushRuleAction { enable, disable, put, putActions, delete } diff --git a/lib/widgets/settings/keyword_field.dart b/lib/widgets/settings/keyword_field.dart new file mode 100644 index 0000000..3d4ee61 --- /dev/null +++ b/lib/widgets/settings/keyword_field.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 36ccd36..24ffa38 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1032,6 +1032,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bf1debb..ac37c12 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -107,6 +107,7 @@ dependencies: app_links: 7.2.2 file_selector: 1.1.0 material_ui: 1.6.0 + multi_state_switch: 1.1.0 unifiedpush: 6.2.0 unifiedpush_storage_shared_preferences: 1.0.0 flutter_local_notifications: 22.3.1