From 7dfd47a404262939c1f14c64db668ef8141f09d6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 3 Dec 2025 16:16:30 -0500 Subject: [PATCH] Leave room support, persist last room, fixes --- .vscode/settings.json | 2 +- lib/controllers/client_controller.dart | 5 +- lib/controllers/current_room_controller.dart | 24 --- lib/controllers/key_controller.dart | 30 ++++ lib/controllers/message_controller.dart | 6 +- lib/controllers/selected_room_controller.dart | 33 +++- .../selected_space_controller.dart | 18 ++- lib/controllers/shared_prefs_controller.dart | 12 ++ lib/controllers/spaces_controller.dart | 7 + lib/helpers/extensions/event_to_message.dart | 19 ++- lib/main.dart | 35 +++- lib/models/space.dart | 1 + lib/widgets/chat_page/room_chat.dart | 18 +-- lib/widgets/chat_page/room_menu.dart | 28 +++- lib/widgets/chat_page/sidebar.dart | 153 ++++++++++-------- pubspec.lock | 56 +++++++ pubspec.yaml | 1 + 17 files changed, 312 insertions(+), 136 deletions(-) delete mode 100644 lib/controllers/current_room_controller.dart create mode 100644 lib/controllers/key_controller.dart create mode 100644 lib/controllers/shared_prefs_controller.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index e2e527c..30f4254 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Appbar", "Displayname"] + "cSpell.words": ["Appbar", "Displayname", "prefs"] } diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 3c7740e..f3db979 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -11,6 +11,7 @@ import "package:nexus/models/session_backup.dart"; class ClientController extends AsyncNotifier { static const sessionBackupKey = "sessionBackup"; + @override Future build() async { if (!voz.isInitialized()) await voz_fl.init(); @@ -43,10 +44,6 @@ class ClientController extends AsyncNotifier { ); } - ref.onDispose( - client.onRoomState.stream.listen((_) => ref.notifyListeners()).cancel, - ); - return client; } diff --git a/lib/controllers/current_room_controller.dart b/lib/controllers/current_room_controller.dart deleted file mode 100644 index bcef708..0000000 --- a/lib/controllers/current_room_controller.dart +++ /dev/null @@ -1,24 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/get_full_room.dart"; -import "package:nexus/models/full_room.dart"; - -class CurrentRoomController extends AsyncNotifier { - @override - Future build() async { - final spaces = await ref.watch(SpacesController.provider.future); - - if (spaces.isEmpty || spaces[0].children.isEmpty) return null; - return spaces[0].children[0].roomData.fullRoom; - } - - Future set(FullRoom room) async { - await future; - state = AsyncValue.data(room); - } - - static final provider = - AsyncNotifierProvider( - CurrentRoomController.new, - ); -} diff --git a/lib/controllers/key_controller.dart b/lib/controllers/key_controller.dart new file mode 100644 index 0000000..946892e --- /dev/null +++ b/lib/controllers/key_controller.dart @@ -0,0 +1,30 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/shared_prefs_controller.dart"; + +class KeyController extends Notifier { + final String key; + KeyController(this.key); + + static const String spaceKey = "space"; + static const String roomKey = "room"; + + @override + String? build() => + ref.watch(SharedPrefsController.provider).requireValue.getString(key); + + Future set(String? id) async { + final prefs = ref.watch(SharedPrefsController.provider).requireValue; + state = id; + + if (id == null) { + prefs.remove(key); + } else { + prefs.setString(key, id); + } + } + + static final provider = + NotifierProvider.family( + KeyController.new, + ); +} diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index 7af35c3..2cf8209 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -1,6 +1,6 @@ import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/current_room_controller.dart"; +import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/helpers/extensions/event_to_message.dart"; class MessageController extends AsyncNotifier { @@ -9,11 +9,11 @@ class MessageController extends AsyncNotifier { @override Future build() async { - final room = await ref.watch(CurrentRoomController.provider.future); + final room = await ref.watch(SelectedRoomController.provider.future); if (room == null) return null; final event = await room.roomData.getEventById(id); - return (await event?.toMessage(mustBeText: true)) as TextMessage; + return (await event?.toMessage(mustBeText: true)) as TextMessage?; } static final provider = diff --git a/lib/controllers/selected_room_controller.dart b/lib/controllers/selected_room_controller.dart index f138b40..4b01014 100644 --- a/lib/controllers/selected_room_controller.dart +++ b/lib/controllers/selected_room_controller.dart @@ -1,12 +1,33 @@ +import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key_controller.dart"; +import "package:nexus/controllers/selected_space_controller.dart"; +import "package:nexus/models/full_room.dart"; -class SelectedRoomController extends Notifier { +class SelectedRoomController extends AsyncNotifier { @override - int build() => 0; + bool updateShouldNotify( + AsyncValue previous, + AsyncValue next, + ) => + previous.value?.avatar != next.value?.avatar || + previous.value?.title != next.value?.title; - void set(int value) => state = value; + @override + Future build() async { + final space = await ref.watch(SelectedSpaceController.provider.future); + final selectedRoomId = ref.watch( + KeyController.provider(KeyController.roomKey), + ); - static final provider = NotifierProvider( - SelectedRoomController.new, - ); + return space.children.firstWhereOrNull( + (room) => room.roomData.id == selectedRoomId, + ) ?? + space.children.firstOrNull; + } + + static final provider = + AsyncNotifierProvider( + SelectedRoomController.new, + ); } diff --git a/lib/controllers/selected_space_controller.dart b/lib/controllers/selected_space_controller.dart index 5b42d23..cbefeb5 100644 --- a/lib/controllers/selected_space_controller.dart +++ b/lib/controllers/selected_space_controller.dart @@ -1,12 +1,22 @@ +import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key_controller.dart"; +import "package:nexus/controllers/spaces_controller.dart"; +import "package:nexus/models/space.dart"; -class SelectedSpaceController extends Notifier { +class SelectedSpaceController extends AsyncNotifier { @override - int build() => 0; + Future build() async { + final spaces = await ref.watch(SpacesController.provider.future); + final selectedSpaceId = ref.watch( + KeyController.provider(KeyController.spaceKey), + ); - void set(int value) => state = value; + return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? + spaces.first; + } - static final provider = NotifierProvider( + static final provider = AsyncNotifierProvider( SelectedSpaceController.new, ); } diff --git a/lib/controllers/shared_prefs_controller.dart b/lib/controllers/shared_prefs_controller.dart new file mode 100644 index 0000000..f4dcdae --- /dev/null +++ b/lib/controllers/shared_prefs_controller.dart @@ -0,0 +1,12 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +class SharedPrefsController extends AsyncNotifier { + @override + Future build() => SharedPreferences.getInstance(); + + static final provider = + AsyncNotifierProvider( + SharedPrefsController.new, + ); +} diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart index eb7d36c..92e0d5d 100644 --- a/lib/controllers/spaces_controller.dart +++ b/lib/controllers/spaces_controller.dart @@ -11,6 +11,10 @@ class SpacesController extends AsyncNotifier> { Future> build() async { final client = await ref.watch(ClientController.provider.future); + ref.onDispose( + client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, + ); + final topLevel = await Future.wait( client.rooms .where((room) => !room.isDirectChat) @@ -33,12 +37,14 @@ class SpacesController extends AsyncNotifier> { Space( client: client, title: "Home", + id: "home", children: topLevelRooms, icon: Icon(Icons.home), ), Space( client: client, title: "Direct Messages", + id: "dms", children: await Future.wait( client.rooms .where((room) => room.isDirectChat) @@ -52,6 +58,7 @@ class SpacesController extends AsyncNotifier> { client: client, title: space.title, avatar: space.avatar, + id: space.roomData.id, roomData: space.roomData, children: await Future.wait( space.roomData.spaceChildren diff --git a/lib/helpers/extensions/event_to_message.dart b/lib/helpers/extensions/event_to_message.dart index 2353c91..cd4ab0f 100644 --- a/lib/helpers/extensions/event_to_message.dart +++ b/lib/helpers/extensions/event_to_message.dart @@ -1,3 +1,4 @@ +import "package:flutter/foundation.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:matrix/matrix.dart"; @@ -28,7 +29,7 @@ extension EventToMessage on Event { ? this.eventId : relationshipEventId ?? this.eventId; - if (redacted) return null; + if (redacted && !mustBeText) return null; final asText = Message.text( @@ -88,13 +89,15 @@ extension EventToMessage on Event { "${senderFromMemoryOrFallback.calcDisplayname()} joined the room.", ), EventTypes.Redaction => null, - EventTypes.Reaction => null, - _ => Message.unsupported( - metadata: metadata, - id: eventId, - authorId: senderId, - replyToMessageId: replyId, - ), + _ => + kDebugMode + ? Message.unsupported( + metadata: metadata, + id: eventId, + authorId: senderId, + replyToMessageId: replyId, + ) + : null, }; } } diff --git a/lib/main.dart b/lib/main.dart index f233848..e755359 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,35 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/pages/chat_page.dart"; import "package:nexus/pages/login_page.dart"; +import "package:nexus/widgets/error_dialog.dart"; import "package:window_manager/window_manager.dart"; import "package:flutter/material.dart"; import "package:dynamic_system_colors/dynamic_system_colors.dart"; import "package:window_size/window_size.dart"; +final GlobalKey navigatorKey = GlobalKey(); + +void showError(Object error, [StackTrace? stackTrace]) { + if (error.toString().contains("DioException")) return; + if (error.toString().contains("UTF-16")) return; + + debugPrintStack(stackTrace: stackTrace, label: error.toString()); + if (navigatorKey.currentContext != null) { + Future.delayed( + Duration.zero, + () => showDialog( + context: navigatorKey.currentContext!, + builder: (_) => ErrorDialog(error, stackTrace), + barrierDismissible: false, + ), + ); + } +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -17,6 +38,9 @@ void main() async { WindowOptions(titleBarStyle: TitleBarStyle.hidden), ); + FlutterError.onError = (FlutterErrorDetails details) => + showError(details.exception.toString(), details.stack); + setWindowMinSize(const Size.square(500)); runApp(ProviderScope(child: const App())); @@ -28,6 +52,7 @@ class App extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => MaterialApp( + navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, // Use indigo to work around bugs in theme generation theme: (lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo)) @@ -40,10 +65,14 @@ class App extends ConsumerWidget { )) .theme, home: ref - .watch(ClientController.provider) + .watch(SharedPrefsController.provider) .betterWhen( - data: (client) => - client.accessToken == null ? LoginPage() : ChatPage(), + data: (_) => ref + .watch(ClientController.provider) + .betterWhen( + data: (client) => + client.accessToken == null ? LoginPage() : ChatPage(), + ), ), ), ); diff --git a/lib/models/space.dart b/lib/models/space.dart index 047bac6..79c2176 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -8,6 +8,7 @@ part "space.freezed.dart"; abstract class Space with _$Space { const factory Space({ required String title, + required String id, required List children, required Client client, Room? roomData, diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 9170e9c..2a23d94 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -1,4 +1,3 @@ -import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_ui/flutter_chat_ui.dart"; @@ -9,7 +8,7 @@ import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; import "package:flyer_chat_text_message/flyer_chat_text_message.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/current_room_controller.dart"; +import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; @@ -38,7 +37,7 @@ class RoomChat extends HookConsumerWidget { final theme = Theme.of(context); return ref - .watch(CurrentRoomController.provider) + .watch(SelectedRoomController.provider) .betterWhen( data: (room) { if (room == null) { @@ -291,13 +290,12 @@ class RoomChat extends HookConsumerWidget { index, { required bool isSentByMe, MessageGroupStatus? groupStatus, - }) => kDebugMode - ? Text( - "${message.authorId} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall - ?.copyWith(color: Colors.grey), - ) - : SizedBox.shrink(), + }) => Text( + "${message.authorId} sent ${message.metadata?["eventType"]}", + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), + ), ), onMessageSend: (message) { notifier.send( diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart index 10edc26..82b7133 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/chat_page/room_menu.dart @@ -20,8 +20,32 @@ class RoomMenu extends StatelessWidget { child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), ), PopupMenuItem( - onTap: () => - showDialog(context: context, builder: (context) => AlertDialog()), + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("Leave Room"), + content: Text( + "Are you sure you want to leave \"${room.getLocalizedDisplayname()}\"?", + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final snackbar = ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Leaving room..."))); + await room.leave(); + snackbar.close(); + }, + child: Text("Leave"), + ), + ], + ), + ), child: ListTile( leading: Icon(Icons.logout, color: danger), title: Text("Leave", style: TextStyle(color: danger)), diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart index 34abea9..aa39ce3 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/chat_page/sidebar.dart @@ -1,8 +1,7 @@ import "package:collection/collection.dart"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/current_room_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; +import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/selected_space_controller.dart"; import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; @@ -16,11 +15,15 @@ class Sidebar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selectedSpaceProvider = SelectedSpaceController.provider; + final selectedSpaceProvider = KeyController.provider( + KeyController.spaceKey, + ); final selectedSpace = ref.watch(selectedSpaceProvider); final selectedSpaceNotifier = ref.watch(selectedSpaceProvider.notifier); - final selectedRoomController = SelectedRoomController.provider; + final selectedRoomController = KeyController.provider( + KeyController.roomKey, + ); final selectedRoom = ref.watch(selectedRoomController); final selectedRoomNotifier = ref.watch(selectedRoomController.notifier); @@ -36,73 +39,87 @@ class Sidebar extends HookConsumerWidget { debugPrintStack(label: error.toString(), stackTrace: stack); throw error; }, - data: (spaces) => NavigationRail( - scrollable: true, - onDestinationSelected: (value) { - selectedRoomNotifier.set(0); - selectedSpaceNotifier.set(value); - ref - .watch(CurrentRoomController.provider.notifier) - .set(spaces[value].children[0]); - }, - destinations: spaces - .map( - (space) => NavigationRailDestination( - icon: AvatarOrHash( - space.avatar, - fallback: space.icon, - space.title, - headers: space.client.headers, - hasBadge: - space.children.firstWhereOrNull( - (room) => room.roomData.hasNewMessages, - ) != - null, + data: (spaces) { + final indexOfSelected = spaces.indexWhere( + (space) => space.id == selectedSpace, + ); + final selectedIndex = indexOfSelected == -1 + ? null + : indexOfSelected; + + return NavigationRail( + scrollable: true, + onDestinationSelected: (value) { + selectedSpaceNotifier.set(spaces[value].roomData?.id); + selectedRoomNotifier.set( + spaces[value].children.firstOrNull?.roomData.id, + ); + }, + destinations: spaces + .map( + (space) => NavigationRailDestination( + icon: AvatarOrHash( + space.avatar, + fallback: space.icon, + space.title, + headers: space.client.headers, + hasBadge: + space.children.firstWhereOrNull( + (room) => room.roomData.hasNewMessages, + ) != + null, + ), + label: Text(space.title), + padding: EdgeInsets.only(top: 4), ), - label: Text(space.title), - padding: EdgeInsets.only(top: 4), - ), - ) - .toList(), - selectedIndex: selectedSpace, - trailingAtBottom: true, - trailing: Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Column( - spacing: 8, - children: [ - IconButton( - onPressed: () => Navigator.of(context).push( - // TODO: join or create room/space - MaterialPageRoute(builder: (_) => SettingsPage()), + ) + .toList(), + selectedIndex: selectedIndex, + trailingAtBottom: true, + trailing: Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Column( + spacing: 8, + children: [ + IconButton( + onPressed: () => Navigator.of(context).push( + // TODO: join or create room/space + MaterialPageRoute(builder: (_) => SettingsPage()), + ), + icon: Icon(Icons.add), ), - icon: Icon(Icons.add), - ), - IconButton( - onPressed: () => Navigator.of(context).push( - // TODO: explore public rooms/spaces - MaterialPageRoute(builder: (_) => SettingsPage()), + IconButton( + onPressed: () => Navigator.of(context).push( + // TODO: explore public rooms/spaces + MaterialPageRoute(builder: (_) => SettingsPage()), + ), + icon: Icon(Icons.explore), ), - icon: Icon(Icons.explore), - ), - IconButton( - onPressed: () => Navigator.of(context).push( - // TODO: explore public rooms/spaces - MaterialPageRoute(builder: (_) => SettingsPage()), + IconButton( + onPressed: () => Navigator.of(context).push( + // TODO: explore public rooms/spaces + MaterialPageRoute(builder: (_) => SettingsPage()), + ), + icon: Icon(Icons.settings), ), - icon: Icon(Icons.settings), - ), - ], + ], + ), ), - ), - ), + ); + }, ), Expanded( child: ref - .watch(SpacesController.provider) + .watch(SelectedSpaceController.provider) .betterWhen( - data: (spaces) { - final space = spaces[selectedSpace]; + data: (space) { + final indexOfSelected = space.children.indexWhere( + (room) => room.roomData.id == selectedRoom, + ); + final selectedIndex = indexOfSelected == -1 + ? null + : indexOfSelected; + return Scaffold( backgroundColor: Colors.transparent, appBar: AppBar( @@ -125,9 +142,7 @@ class Sidebar extends HookConsumerWidget { scrollable: true, backgroundColor: Colors.transparent, extended: true, - selectedIndex: space.children.isEmpty - ? null - : selectedRoom, + selectedIndex: selectedIndex, destinations: space.children .map( (room) => NavigationRailDestination( @@ -144,12 +159,8 @@ class Sidebar extends HookConsumerWidget { ), ) .toList(), - onDestinationSelected: (value) { - selectedRoomNotifier.set(value); - ref - .watch(CurrentRoomController.provider.notifier) - .set(space.children[value]); - }, + onDestinationSelected: (value) => selectedRoomNotifier + .set(space.children[value].roomData.id), ), ); }, diff --git a/pubspec.lock b/pubspec.lock index f4b6c61..54d15f6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1200,6 +1200,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 95441fe..0796224 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: json_annotation: ^4.9.0 vodozemac: ^0.4.0 clipboard: ^2.0.2 + shared_preferences: ^2.5.3 dev_dependencies: build_runner: ^2.4.11