From 9d563793c7a4624dbd88ea16cde0785c3d9f409a Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 27 Oct 2025 23:53:48 +0100 Subject: [PATCH] crafting a custom context-menu --- CHANGELOG.md | 13 + lib/main.dart | 4 + lib/src/database/daos/contacts.dao.dart | 4 +- lib/src/database/daos/groups.dao.dart | 1 - .../reaction.server_message.dart | 3 +- lib/src/services/api/utils.dart | 19 - .../mediafiles/mediafile.service.dart | 77 +++- .../mediafiles/thumbnail.service.dart | 30 -- lib/src/utils/misc.dart | 26 -- lib/src/views/chats/add_new_user.view.dart | 18 +- lib/src/views/chats/archived_chats.view.dart | 54 +++ lib/src/views/chats/chat_list.view.dart | 375 ++++++++++-------- .../chat_list_components/group_list_item.dart | 194 +++++---- lib/src/views/chats/chat_messages.view.dart | 246 ++++++------ .../chat_list_entry.dart | 5 +- .../message_context_menu.dart | 66 +-- lib/src/views/chats/message_info.view.dart | 24 +- lib/src/views/chats/start_new_chat.view.dart | 48 +-- .../components/avatar_icon.component.dart | 14 +- .../components/context_menu.component.dart | 99 +++++ .../group_context_menu.component.dart | 84 ++-- .../user_context_menu.component.dart | 26 +- lib/src/views/contact/contact.view.dart | 25 +- lib/src/views/home.view.dart | 156 ++++---- .../settings/privacy_view_block.users.dart | 82 ++-- pubspec.lock | 13 +- pubspec.yaml | 3 - 27 files changed, 935 insertions(+), 774 deletions(-) create mode 100644 lib/src/views/chats/archived_chats.view.dart create mode 100644 lib/src/views/components/context_menu.component.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5121631..e04885f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.0.62 + +- Support for Groups +- Editing of text messages +- Deletion of messages +- Various UI improvements like a new context-menu +- Client-to-client (C2C) protocol converted to ProtoBuf +- Use of UUIDs in the database +- Completely new database schema +- Improved reliability of C2C messages +- Improved video handling +- Various bug fixes + ## 0.0.61 - Improving image editor when changing colors diff --git a/lib/main.dart b/lib/main.dart index a951611..1004f5c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ // ignore_for_file: unused_import +import 'dart:async'; import 'dart:io'; import 'package:camera/camera.dart'; @@ -17,6 +18,7 @@ import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/services/api.service.dart'; import 'package:twonly/src/services/api/mediafiles/media_background.service.dart'; import 'package:twonly/src/services/fcm.service.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/storage.dart'; @@ -54,6 +56,8 @@ void main() async { await initFileDownloader(); + unawaited(MediaFileService.purgeTempFolder()); + // await twonlyDB.messagesDao.resetPendingDownloadState(); // await twonlyDB.messageRetransmissionDao.purgeOldRetransmissions(); // await twonlyDB.signalDao.purgeOutDatedPreKeys(); diff --git a/lib/src/database/daos/contacts.dao.dart b/lib/src/database/daos/contacts.dao.dart index d1cff2e..6792ad0 100644 --- a/lib/src/database/daos/contacts.dao.dart +++ b/lib/src/database/daos/contacts.dao.dart @@ -131,8 +131,8 @@ String getContactDisplayName(Contact user) { if (user.accountDeleted) { name = applyStrikethrough(name); } - if (name.length > 12) { - return '${name.substring(0, 12)}...'; + if (name.length > 27) { + return '${name.substring(0, 27 - 3)}...'; } return name; } diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 27eec3f..b5c741d 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -113,7 +113,6 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { Stream> watchGroupsForChatList() { return (select(groups) - ..where((t) => t.archived.equals(false)) ..orderBy([(t) => OrderingTerm.desc(t.lastMessageExchange)])) .watch(); } diff --git a/lib/src/services/api/server_messages/reaction.server_message.dart b/lib/src/services/api/server_messages/reaction.server_message.dart index daf4247..1eaf4db 100644 --- a/lib/src/services/api/server_messages/reaction.server_message.dart +++ b/lib/src/services/api/server_messages/reaction.server_message.dart @@ -22,6 +22,7 @@ Future handleReaction( groupId, reaction.emoji, ); - return; + await twonlyDB.groupsDao + .increaseLastMessageExchange(groupId, DateTime.now()); } } diff --git a/lib/src/services/api/utils.dart b/lib/src/services/api/utils.dart index 81da8c9..7357d06 100644 --- a/lib/src/services/api/utils.dart +++ b/lib/src/services/api/utils.dart @@ -56,25 +56,6 @@ ClientToServer createClientToServerFromApplicationData( return ClientToServer()..v0 = v0; } -Future rejectAndHideContact(int contactId) async { - await sendCipherText( - contactId, - EncryptedContent( - contactRequest: EncryptedContent_ContactRequest( - type: EncryptedContent_ContactRequest_Type.REJECT, - ), - ), - ); - await twonlyDB.contactsDao.updateContact( - contactId, - const ContactsCompanion( - accepted: Value(false), - requested: Value(false), - deletedByUser: Value(true), - ), - ); -} - Future handleMediaError(MediaFile media) async { await twonlyDB.mediaFilesDao.updateMedia( media.mediaId, diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index a32dc62..e0fcb7e 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -32,6 +32,61 @@ class MediaFileService { ); } + static Future purgeTempFolder() async { + final tempDirectory = MediaFileService._buildDirectoryPath( + 'tmp', + await getApplicationSupportDirectory(), + ); + + final files = tempDirectory.listSync(); + for (final file in files) { + final mediaId = basename(file.path).split('.').first; + + var delete = true; + + final service = await MediaFileService.fromMediaId(mediaId); + if (service == null) { + Log.error( + 'Purging media file, as it is not in the database $mediaId.', + ); + } else { + final messages = + await twonlyDB.messagesDao.getMessagesByMediaId(mediaId); + + for (final message in messages) { + if (message.senderId == null) { + // Media was send by me + if (message.openedAt == null) { + // Message was not yet opened from all persons, so wait... + delete = false; + } else if (service.mediaFile.requiresAuthentication || + service.mediaFile.displayLimitInMilliseconds != null) { + // Message was opened by all persons, and they can not reopen the image. + // delete = true; // do not overwrite a previous delete = false + // this is just to make it easier to understand :) + } else if (message.openedAt! + .isAfter(DateTime.now().subtract(const Duration(days: 2)))) { + // Message was opened by all persons, as it can be reopened and then stored by a other person keep it for + // two day just to be sure. + delete = false; + } + } else { + // this media was received from another person + if (message.openedAt == null) { + // Message was not yet opened, so do not remove it. + delete = false; + } + } + } + } + + if (delete) { + Log.info('Purging media file $mediaId'); + file.deleteSync(); + } + } + } + Future updateFromDB() async { final updated = await twonlyDB.mediaFilesDao.getMediaFileById(mediaFile.mediaId); @@ -89,7 +144,8 @@ class MediaFileService { } switch (mediaFile.type) { case MediaType.image: - await createThumbnailsForImage(storedPath, thumbnailPath); + // all images are already compress.. + break; case MediaType.video: await createThumbnailsForVideo(storedPath, thumbnailPath); case MediaType.gif: @@ -163,11 +219,10 @@ class MediaFileService { await updateFromDB(); } - File _buildFilePath( - String directory, { - String namePrefix = '', - String extensionParam = '', - }) { + static Directory _buildDirectoryPath( + String directory, + Directory applicationSupportDirectory, + ) { final mediaBaseDir = Directory( join( applicationSupportDirectory.path, @@ -178,6 +233,14 @@ class MediaFileService { if (!mediaBaseDir.existsSync()) { mediaBaseDir.createSync(recursive: true); } + return mediaBaseDir; + } + + File _buildFilePath( + String directory, { + String namePrefix = '', + String extensionParam = '', + }) { var extension = extensionParam; if (extension == '') { switch (mediaFile.type) { @@ -189,6 +252,8 @@ class MediaFileService { extension = 'gif'; } } + final mediaBaseDir = + _buildDirectoryPath(directory, applicationSupportDirectory); return File( join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'), ); diff --git a/lib/src/services/mediafiles/thumbnail.service.dart b/lib/src/services/mediafiles/thumbnail.service.dart index 5af964d..e73c8e4 100644 --- a/lib/src/services/mediafiles/thumbnail.service.dart +++ b/lib/src/services/mediafiles/thumbnail.service.dart @@ -1,37 +1,7 @@ import 'dart:io'; -import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; -Future createThumbnailsForImage( - File sourceFile, - File destinationFile, -) async { - final fileExtension = sourceFile.path.split('.').last.toLowerCase(); - if (fileExtension != 'png') { - Log.error('Could not create thumbnail for image. $fileExtension != png'); - return; - } - - try { - final imageBytesCompressed = await FlutterImageCompress.compressWithFile( - minHeight: 800, - minWidth: 450, - sourceFile.path, - format: CompressFormat.webp, - quality: 50, - ); - - if (imageBytesCompressed == null) { - Log.error('Could not compress the image'); - return; - } - await destinationFile.writeAsBytes(imageBytesCompressed); - } catch (e) { - Log.error('Could not compress the image got :$e'); - } -} - Future createThumbnailsForVideo( File sourceFile, File destinationFile, diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index d86d788..b488fdb 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -8,7 +8,6 @@ import 'package:gal/gal.dart'; import 'package:intl/intl.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:local_auth/local_auth.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:provider/provider.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; @@ -270,31 +269,6 @@ Uint8List hexToUint8List(String hex) => Uint8List.fromList( ), ); -PieTheme getPieCanvasTheme(BuildContext context) { - return PieTheme( - brightness: Theme.of(context).brightness, - rightClickShowsMenu: true, - radius: 70, - buttonTheme: PieButtonTheme( - backgroundColor: Theme.of(context).colorScheme.tertiary, - iconColor: Theme.of(context).colorScheme.surfaceBright, - ), - buttonThemeHovered: PieButtonTheme( - backgroundColor: Theme.of(context).colorScheme.primary, - iconColor: Theme.of(context).colorScheme.surfaceBright, - ), - tooltipPadding: const EdgeInsets.all(20), - overlayColor: isDarkMode(context) - ? const Color.fromARGB(69, 0, 0, 0) - : const Color.fromARGB(40, 0, 0, 0), - // spacing: 0, - tooltipTextStyle: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w600, - ), - ); -} - Color getMessageColorFromType( Message message, MediaFile? mediaFile, diff --git a/lib/src/views/chats/add_new_user.view.dart b/lib/src/views/chats/add_new_user.view.dart index 5bdf57d..7ab04b5 100644 --- a/lib/src/views/chats/add_new_user.view.dart +++ b/lib/src/views/chats/add_new_user.view.dart @@ -8,7 +8,6 @@ import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/services/api/messages.dart'; -import 'package:twonly/src/services/api/utils.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -223,7 +222,22 @@ class ContactsListView extends StatelessWidget { child: IconButton( icon: const Icon(Icons.close, color: Colors.red), onPressed: () async { - await rejectAndHideContact(contact.userId); + await sendCipherText( + contact.userId, + EncryptedContent( + contactRequest: EncryptedContent_ContactRequest( + type: EncryptedContent_ContactRequest_Type.REJECT, + ), + ), + ); + await twonlyDB.contactsDao.updateContact( + contact.userId, + const ContactsCompanion( + accepted: Value(false), + requested: Value(false), + deletedByUser: Value(true), + ), + ); }, ), ), diff --git a/lib/src/views/chats/archived_chats.view.dart b/lib/src/views/chats/archived_chats.view.dart new file mode 100644 index 0000000..e769afa --- /dev/null +++ b/lib/src/views/chats/archived_chats.view.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/views/chats/chat_list_components/group_list_item.dart'; + +class ArchivedChatsView extends StatefulWidget { + const ArchivedChatsView({super.key}); + + @override + State createState() => _ArchivedChatsViewState(); +} + +class _ArchivedChatsViewState extends State { + List _groupsArchived = []; + late StreamSubscription> _contactsSub; + + @override + void initState() { + initAsync(); + super.initState(); + } + + Future initAsync() async { + final stream = twonlyDB.groupsDao.watchGroupsForChatList(); + _contactsSub = stream.listen((groups) { + setState(() { + _groupsArchived = groups.where((x) => x.archived).toList(); + }); + }); + } + + @override + void dispose() { + _contactsSub.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Archivierte Chats"), + ), + body: ListView( + children: _groupsArchived.map((group) { + return GroupListItem( + group: group, + ); + }).toList(), + ), + ); + } +} diff --git a/lib/src/views/chats/chat_list.view.dart b/lib/src/views/chats/chat_list.view.dart index ae9e4e1..2bbf609 100644 --- a/lib/src/views/chats/chat_list.view.dart +++ b/lib/src/views/chats/chat_list.view.dart @@ -11,6 +11,7 @@ import 'package:twonly/src/providers/connection.provider.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/chats/add_new_user.view.dart'; +import 'package:twonly/src/views/chats/archived_chats.view.dart'; import 'package:twonly/src/views/chats/chat_list_components/connection_info.comp.dart'; import 'package:twonly/src/views/chats/chat_list_components/feedback_btn.dart'; import 'package:twonly/src/views/chats/chat_list_components/group_list_item.dart'; @@ -33,8 +34,8 @@ class _ChatListViewState extends State { late StreamSubscription> _contactsSub; List _groupsNotPinned = []; List _groupsPinned = []; + List _groupsArchived = []; - GlobalKey firstUserListItemKey = GlobalKey(); GlobalKey searchForOtherUsers = GlobalKey(); Timer? tutorial; bool showFeedbackShortcut = false; @@ -49,8 +50,10 @@ class _ChatListViewState extends State { final stream = twonlyDB.groupsDao.watchGroupsForChatList(); _contactsSub = stream.listen((groups) { setState(() { - _groupsNotPinned = groups.where((x) => !x.pinned).toList(); - _groupsPinned = groups.where((x) => x.pinned).toList(); + _groupsNotPinned = + groups.where((x) => !x.pinned && !x.archived).toList(); + _groupsPinned = groups.where((x) => x.pinned && !x.archived).toList(); + _groupsArchived = groups.where((x) => x.archived).toList(); }); }); @@ -59,9 +62,9 @@ class _ChatListViewState extends State { if (!mounted) return; await showChatListTutorialSearchOtherUsers(context, searchForOtherUsers); if (!mounted) return; - if (_groupsNotPinned.isNotEmpty) { - await showChatListTutorialContextMenu(context, firstUserListItemKey); - } + // if (_groupsNotPinned.isNotEmpty) { + // await showChatListTutorialContextMenu(context, firstUserListItemKey); + // } }); final changeLog = await rootBundle.loadString('CHANGELOG.md'); @@ -102,193 +105,219 @@ class _ChatListViewState extends State { Widget build(BuildContext context) { final isConnected = context.watch().isConnected; final planId = context.watch().plan; - return Scaffold( - appBar: AppBar( - title: Row( - children: [ - GestureDetector( - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const ProfileView(); - }, - ), - ); - if (!mounted) return; - setState(() {}); // gUser has updated - }, - child: AvatarIcon( - userData: gUser, - fontSize: 14, - color: context.color.onSurface.withAlpha(20), - ), - ), - const SizedBox(width: 10), - const Text('twonly '), - if (planId != 'Free') + return Container( + child: Scaffold( + appBar: AppBar( + title: Row( + children: [ GestureDetector( - onTap: () { - Navigator.push( + onTap: () async { + await Navigator.push( context, MaterialPageRoute( builder: (context) { - return const SubscriptionView(); + return const ProfileView(); }, ), ); + if (!mounted) return; + setState(() {}); // gUser has updated }, - child: Container( - decoration: BoxDecoration( - color: context.color.primary, - borderRadius: BorderRadius.circular(15), - ), - padding: - const EdgeInsets.symmetric(horizontal: 5, vertical: 3), - child: Text( - planId, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: isDarkMode(context) ? Colors.black : Colors.white, - ), - ), + child: AvatarIcon( + userData: gUser, + fontSize: 14, + color: context.color.onSurface.withAlpha(20), ), ), - ], - ), - actions: [ - const FeedbackIconButton(), - StreamBuilder( - stream: twonlyDB.contactsDao.watchContactsRequested(), - builder: (context, snapshot) { - var count = 0; - if (snapshot.hasData && snapshot.data != null) { - count = snapshot.data!; - } - return NotificationBadge( - count: count.toString(), - child: IconButton( - key: searchForOtherUsers, - icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), - onPressed: () { + const SizedBox(width: 10), + const Text('twonly '), + if (planId != 'Free') + GestureDetector( + onTap: () { Navigator.push( context, MaterialPageRoute( - builder: (context) => const AddNewUserView(), + builder: (context) { + return const SubscriptionView(); + }, ), ); }, + child: Container( + decoration: BoxDecoration( + color: context.color.primary, + borderRadius: BorderRadius.circular(15), + ), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + child: Text( + planId, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: + isDarkMode(context) ? Colors.black : Colors.white, + ), + ), + ), + ), + ], + ), + actions: [ + const FeedbackIconButton(), + StreamBuilder( + stream: twonlyDB.contactsDao.watchContactsRequested(), + builder: (context, snapshot) { + var count = 0; + if (snapshot.hasData && snapshot.data != null) { + count = snapshot.data!; + } + return NotificationBadge( + count: count.toString(), + child: IconButton( + key: searchForOtherUsers, + icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AddNewUserView(), + ), + ); + }, + ), + ); + }, + ), + IconButton( + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsMainView(), + ), + ); + if (!mounted) return; + setState(() {}); // gUser may has changed... + }, + icon: const FaIcon(FontAwesomeIcons.gear, size: 19), + ), + ], + ), + body: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: isConnected ? Container() : const ConnectionInfo(), + ), + Positioned.fill( + child: Container( + child: RefreshIndicator( + onRefresh: () async { + await apiService.close(() {}); + await apiService.connect(force: true); + await Future.delayed(const Duration(seconds: 1)); + }, + child: (_groupsNotPinned.isEmpty && + _groupsPinned.isEmpty && + _groupsArchived.isEmpty) + ? Center( + child: Padding( + padding: const EdgeInsets.all(10), + child: OutlinedButton.icon( + icon: const Icon(Icons.person_add), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const AddNewUserView(), + ), + ); + }, + label: Text( + context.lang.chatListViewSearchUserNameBtn), + ), + ), + ) + : ListView.builder( + itemCount: _groupsPinned.length + + (_groupsPinned.isNotEmpty ? 1 : 0) + + _groupsNotPinned.length + + (_groupsArchived.isNotEmpty ? 1 : 0), + itemBuilder: (context, index) { + if (index >= + _groupsNotPinned.length + + _groupsPinned.length + + (_groupsPinned.isNotEmpty ? 1 : 0)) { + if (_groupsArchived.isEmpty) return Container(); + return ListTile( + title: Text( + "Archivierte Chats (${_groupsArchived.length})", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13), + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return ArchivedChatsView(); + }, + ), + ); + }, + ); + } + // Check if the index is for the pinned users + if (index < _groupsPinned.length) { + final group = _groupsPinned[index]; + return GroupListItem( + key: ValueKey(group.groupId), + group: group, + ); + } + + // If there are pinned users, account for the Divider + var adjustedIndex = index - _groupsPinned.length; + if (_groupsPinned.isNotEmpty && + adjustedIndex == 0) { + return const Divider(); + } + + // Adjust the index for the contacts list + adjustedIndex -= (_groupsPinned.isNotEmpty ? 1 : 0); + + // Get the contacts that are not pinned + final group = _groupsNotPinned.elementAt( + adjustedIndex, + ); + return GroupListItem( + key: ValueKey(group.groupId), group: group); + }, + ), + ), + ), + ), + ], + ), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: 30), + child: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return const StartNewChatView(); + }, ), ); }, + child: const FaIcon(FontAwesomeIcons.penToSquare), ), - IconButton( - onPressed: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsMainView(), - ), - ); - if (!mounted) return; - setState(() {}); // gUser may has changed... - }, - icon: const FaIcon(FontAwesomeIcons.gear, size: 19), - ), - ], - ), - body: Stack( - children: [ - Positioned( - top: 0, - left: 0, - right: 0, - child: isConnected ? Container() : const ConnectionInfo(), - ), - Positioned.fill( - child: RefreshIndicator( - onRefresh: () async { - await apiService.close(() {}); - await apiService.connect(force: true); - await Future.delayed(const Duration(seconds: 1)); - }, - child: (_groupsNotPinned.isEmpty && _groupsPinned.isEmpty) - ? Center( - child: Padding( - padding: const EdgeInsets.all(10), - child: OutlinedButton.icon( - icon: const Icon(Icons.person_add), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const AddNewUserView(), - ), - ); - }, - label: - Text(context.lang.chatListViewSearchUserNameBtn), - ), - ), - ) - : ListView.builder( - itemCount: _groupsPinned.length + - (_groupsPinned.isNotEmpty ? 1 : 0) + - _groupsNotPinned.length, - itemBuilder: (context, index) { - // Check if the index is for the pinned users - if (index < _groupsPinned.length) { - final group = _groupsPinned[index]; - return GroupListItem( - key: ValueKey(group.groupId), - group: group, - firstUserListItemKey: (index == 0 || index == 1) - ? firstUserListItemKey - : null, - ); - } - - // If there are pinned users, account for the Divider - var adjustedIndex = index - _groupsPinned.length; - if (_groupsPinned.isNotEmpty && adjustedIndex == 0) { - return const Divider(); - } - - // Adjust the index for the contacts list - adjustedIndex -= (_groupsPinned.isNotEmpty ? 1 : 0); - - // Get the contacts that are not pinned - final group = _groupsNotPinned.elementAt( - adjustedIndex, - ); - return GroupListItem( - key: ValueKey(group.groupId), - group: group, - firstUserListItemKey: - (index == 0) ? firstUserListItemKey : null, - ); - }, - ), - ), - ), - ], - ), - floatingActionButton: Padding( - padding: const EdgeInsets.only(bottom: 30), - child: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const StartNewChatView(); - }, - ), - ); - }, - child: const FaIcon(FontAwesomeIcons.penToSquare), ), ), ); diff --git a/lib/src/views/chats/chat_list_components/group_list_item.dart b/lib/src/views/chats/chat_list_components/group_list_item.dart index 03fba1b..417cf8d 100644 --- a/lib/src/views/chats/chat_list_components/group_list_item.dart +++ b/lib/src/views/chats/chat_list_components/group_list_item.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:mutex/mutex.dart'; @@ -20,32 +21,29 @@ import 'package:twonly/src/views/components/group_context_menu.component.dart'; class GroupListItem extends StatefulWidget { const GroupListItem({ required this.group, - required this.firstUserListItemKey, super.key, }); final Group group; - final GlobalKey? firstUserListItemKey; @override State createState() => _UserListItem(); } class _UserListItem extends State { - MessageSendState state = MessageSendState.send; - Message? currentMessage; + Message? _currentMessage; - List messagesNotOpened = []; - late StreamSubscription> messagesNotOpenedStream; + List _messagesNotOpened = []; + late StreamSubscription> _messagesNotOpenedStream; - Message? lastMessage; - Reaction? lastReaction; - late StreamSubscription lastMessageStream; - late StreamSubscription lastReactionStream; - late StreamSubscription> lastMediaFilesStream; + Message? _lastMessage; + Reaction? _lastReaction; + late StreamSubscription _lastMessageStream; + late StreamSubscription _lastReactionStream; + late StreamSubscription> _lastMediaFilesStream; - List previewMessages = []; - List previewMediaFiles = []; - bool hasNonOpenedMediaFile = false; + List _previewMessages = []; + final List _previewMediaFiles = []; + bool _hasNonOpenedMediaFile = false; @override void initState() { @@ -55,48 +53,48 @@ class _UserListItem extends State { @override void dispose() { - messagesNotOpenedStream.cancel(); - lastReactionStream.cancel(); - lastMessageStream.cancel(); - lastMediaFilesStream.cancel(); + _messagesNotOpenedStream.cancel(); + _lastReactionStream.cancel(); + _lastMessageStream.cancel(); + _lastMediaFilesStream.cancel(); super.dispose(); } void initStreams() { - lastMessageStream = twonlyDB.messagesDao + _lastMessageStream = twonlyDB.messagesDao .watchLastMessage(widget.group.groupId) .listen((update) { protectUpdateState.protect(() async { - await updateState(update, messagesNotOpened); + await updateState(update, _messagesNotOpened); }); }); - lastReactionStream = twonlyDB.reactionsDao + _lastReactionStream = twonlyDB.reactionsDao .watchLastReactions(widget.group.groupId) .listen((update) { setState(() { - lastReaction = update; + _lastReaction = update; }); // protectUpdateState.protect(() async { // await updateState(lastMessage, update, messagesNotOpened); // }); }); - messagesNotOpenedStream = twonlyDB.messagesDao + _messagesNotOpenedStream = twonlyDB.messagesDao .watchMessageNotOpened(widget.group.groupId) .listen((update) { protectUpdateState.protect(() async { - await updateState(lastMessage, update); + await updateState(_lastMessage, update); }); }); - lastMediaFilesStream = + _lastMediaFilesStream = twonlyDB.mediaFilesDao.watchNewestMediaFiles().listen((mediaFiles) { for (final mediaFile in mediaFiles) { - final index = - previewMediaFiles.indexWhere((t) => t.mediaId == mediaFile.mediaId); + final index = _previewMediaFiles + .indexWhere((t) => t.mediaId == mediaFile.mediaId); if (index >= 0) { - previewMediaFiles[index] = mediaFile; + _previewMediaFiles[index] = mediaFile; } } setState(() {}); @@ -111,55 +109,55 @@ class _UserListItem extends State { ) async { if (newLastMessage == null) { // there are no messages at all - currentMessage = null; - previewMessages = []; + _currentMessage = null; + _previewMessages = []; } else if (newMessagesNotOpened.isNotEmpty) { // Filter for the preview non opened messages. First messages which where send but not yet opened by the other side. final receivedMessages = newMessagesNotOpened.where((x) => x.senderId != null).toList(); if (receivedMessages.isNotEmpty) { - previewMessages = receivedMessages; - currentMessage = receivedMessages.first; + _previewMessages = receivedMessages; + _currentMessage = receivedMessages.first; } else { - previewMessages = newMessagesNotOpened; - currentMessage = newMessagesNotOpened.first; + _previewMessages = newMessagesNotOpened; + _currentMessage = newMessagesNotOpened.first; } } else { // there are no not opened messages show just the last message in the table - currentMessage = newLastMessage; - previewMessages = [newLastMessage]; + _currentMessage = newLastMessage; + _previewMessages = [newLastMessage]; } final msgs = - previewMessages.where((x) => x.type == MessageType.media).toList(); + _previewMessages.where((x) => x.type == MessageType.media).toList(); if (msgs.isNotEmpty && msgs.first.type == MessageType.media && msgs.first.senderId != null && msgs.first.openedAt == null) { - hasNonOpenedMediaFile = true; + _hasNonOpenedMediaFile = true; } else { - hasNonOpenedMediaFile = false; + _hasNonOpenedMediaFile = false; } - for (final message in previewMessages) { + for (final message in _previewMessages) { if (message.mediaId != null && - !previewMediaFiles.any((t) => t.mediaId == message.mediaId)) { + !_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) { final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!); if (mediaFile != null) { - previewMediaFiles.add(mediaFile); + _previewMediaFiles.add(mediaFile); } } } - lastMessage = newLastMessage; - messagesNotOpened = newMessagesNotOpened; + _lastMessage = newLastMessage; + _messagesNotOpened = newMessagesNotOpened; if (mounted) setState(() {}); } Future onTap() async { - if (currentMessage == null) { + if (_currentMessage == null) { await Navigator.push( context, MaterialPageRoute( @@ -171,9 +169,9 @@ class _UserListItem extends State { return; } - if (hasNonOpenedMediaFile) { + if (_hasNonOpenedMediaFile) { final msgs = - previewMessages.where((x) => x.type == MessageType.media).toList(); + _previewMessages.where((x) => x.type == MessageType.media).toList(); final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(msgs.first.mediaId!); if (mediaFile?.downloadState == null) return; @@ -207,70 +205,56 @@ class _UserListItem extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - Positioned( - top: 0, - bottom: 0, - left: 50, - child: SizedBox( - key: widget.firstUserListItemKey, - height: 20, - width: 20, - ), + return GroupContextMenu( + group: widget.group, + child: ListTile( + title: Text( + widget.group.groupName, ), - GroupContextMenu( - group: widget.group, - child: ListTile( - title: Text( - widget.group.groupName, - ), - subtitle: (currentMessage == null) - ? Text(context.lang.chatsTapToSend) - : Row( - children: [ - MessageSendStateIcon( - previewMessages, - previewMediaFiles, - lastReaction: lastReaction, - ), - const Text('•'), - const SizedBox(width: 5), - if (currentMessage != null) - LastMessageTime(message: currentMessage!), - FlameCounterWidget( - groupId: widget.group.groupId, - prefix: true, - ), - ], + subtitle: (_currentMessage == null) + ? Text(context.lang.chatsTapToSend) + : Row( + children: [ + MessageSendStateIcon( + _previewMessages, + _previewMediaFiles, + lastReaction: _lastReaction, ), - leading: AvatarIcon(group: widget.group), - trailing: IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - if (hasNonOpenedMediaFile) { - return ChatMessagesView(widget.group); - } else { - return CameraSendToView(widget.group); - } - }, + const Text('•'), + const SizedBox(width: 5), + if (_currentMessage != null) + LastMessageTime(message: _currentMessage!), + FlameCounterWidget( + groupId: widget.group.groupId, + prefix: true, ), - ); - }, - icon: FaIcon( - hasNonOpenedMediaFile - ? FontAwesomeIcons.solidComments - : FontAwesomeIcons.camera, - color: context.color.outline.withAlpha(150), + ], ), - ), - onTap: onTap, + leading: AvatarIcon(group: widget.group), + trailing: IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + if (_hasNonOpenedMediaFile) { + return ChatMessagesView(widget.group); + } else { + return CameraSendToView(widget.group); + } + }, + ), + ); + }, + icon: FaIcon( + _hasNonOpenedMediaFile + ? FontAwesomeIcons.solidComments + : FontAwesomeIcons.camera, + color: context.color.outline.withAlpha(150), ), ), - ], + onTap: onTap, + ), ); } } diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index c29b4c6..5ed959f 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -3,7 +3,6 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:mutex/mutex.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; @@ -255,96 +254,64 @@ class _ChatMessagesViewState extends State { ), ), ), - body: PieCanvas( - theme: getPieCanvasTheme(context), - child: SafeArea( - child: Column( - children: [ - Expanded( - child: ScrollablePositionedList.builder( - reverse: true, - itemCount: messages.length + 1, - itemScrollController: itemScrollController, - itemBuilder: (context, i) { - if (i == messages.length) { - return const Padding( - padding: EdgeInsetsGeometry.only(top: 10), - ); - } - if (messages[i].isDate) { - return ChatDateChip( - item: messages[i], - ); - } else { - final chatMessage = messages[i].message!; - return Transform.translate( - offset: Offset( - (focusedScrollItem == i) - ? (chatMessage.senderId == null) - ? -8 - : 8 - : 0, - 0, - ), - child: Transform.scale( - scale: (focusedScrollItem == i) ? 1.05 : 1, - child: ChatListEntry( - key: Key(chatMessage.messageId), - message: messages[i].message!, - nextMessage: - (i > 0) ? messages[i - 1].message : null, - prevMessage: ((i + 1) < messages.length) - ? messages[i + 1].message - : null, - group: group, - galleryItems: galleryItems, - scrollToMessage: scrollToMessage, - onResponseTriggered: () { - setState(() { - quotesMessage = chatMessage; - }); - textFieldFocus.requestFocus(); - }, - ), - ), - ); - } - }, - ), - ), - if (quotesMessage != null) - Container( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 10, - ), - child: Row( - children: [ - Expanded( - child: ResponsePreview( - message: quotesMessage, - showBorder: true, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: ScrollablePositionedList.builder( + reverse: true, + itemCount: messages.length + 1, + itemScrollController: itemScrollController, + itemBuilder: (context, i) { + if (i == messages.length) { + return const Padding( + padding: EdgeInsetsGeometry.only(top: 10), + ); + } + if (messages[i].isDate) { + return ChatDateChip( + item: messages[i], + ); + } else { + final chatMessage = messages[i].message!; + return Transform.translate( + offset: Offset( + (focusedScrollItem == i) + ? (chatMessage.senderId == null) + ? -8 + : 8 + : 0, + 0, + ), + child: Transform.scale( + scale: (focusedScrollItem == i) ? 1.05 : 1, + child: ChatListEntry( + key: Key(chatMessage.messageId), + message: messages[i].message!, + nextMessage: + (i > 0) ? messages[i - 1].message : null, + prevMessage: ((i + 1) < messages.length) + ? messages[i + 1].message + : null, group: group, + galleryItems: galleryItems, + scrollToMessage: scrollToMessage, + onResponseTriggered: () { + setState(() { + quotesMessage = chatMessage; + }); + textFieldFocus.requestFocus(); + }, ), ), - IconButton( - onPressed: () { - setState(() { - quotesMessage = null; - }); - }, - icon: const FaIcon( - FontAwesomeIcons.xmark, - size: 16, - ), - ), - ], - ), - ), - Padding( + ); + } + }, + ), + ), + if (quotesMessage != null) + Container( padding: const EdgeInsets.only( - bottom: 30, left: 20, right: 20, top: 10, @@ -352,50 +319,79 @@ class _ChatMessagesViewState extends State { child: Row( children: [ Expanded( - child: TextField( - controller: newMessageController, - focusNode: textFieldFocus, - keyboardType: TextInputType.multiline, - maxLines: 4, - minLines: 1, - onChanged: (value) { - currentInputText = value; - setState(() {}); - }, - onSubmitted: (_) { - _sendMessage(); - }, - decoration: inputTextMessageDeco(context), + child: ResponsePreview( + message: quotesMessage, + showBorder: true, + group: group, ), ), - if (currentInputText != '') - IconButton( - padding: const EdgeInsets.all(15), - icon: const FaIcon( - FontAwesomeIcons.solidPaperPlane, - ), - onPressed: _sendMessage, - ) - else - IconButton( - icon: const FaIcon(FontAwesomeIcons.camera), - padding: const EdgeInsets.all(15), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return CameraSendToView(widget.group); - }, - ), - ); - }, + IconButton( + onPressed: () { + setState(() { + quotesMessage = null; + }); + }, + icon: const FaIcon( + FontAwesomeIcons.xmark, + size: 16, ), + ), ], ), ), - ], - ), + Padding( + padding: const EdgeInsets.only( + bottom: 30, + left: 20, + right: 20, + top: 10, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: newMessageController, + focusNode: textFieldFocus, + keyboardType: TextInputType.multiline, + maxLines: 4, + minLines: 1, + onChanged: (value) { + currentInputText = value; + setState(() {}); + }, + onSubmitted: (_) { + _sendMessage(); + }, + decoration: inputTextMessageDeco(context), + ), + ), + if (currentInputText != '') + IconButton( + padding: const EdgeInsets.all(15), + icon: const FaIcon( + FontAwesomeIcons.solidPaperPlane, + ), + onPressed: _sendMessage, + ) + else + IconButton( + icon: const FaIcon(FontAwesomeIcons.camera), + padding: const EdgeInsets.all(15), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return CameraSendToView(widget.group); + }, + ), + ); + }, + ), + ], + ), + ), + ], ), ), ), diff --git a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart index e4b9a19..97d6933 100644 --- a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart @@ -162,7 +162,10 @@ class _ChatListEntryState extends State { message: widget.message, group: widget.group, onResponseTriggered: widget.onResponseTriggered!, - child: child, + galleryItems: widget.galleryItems, + child: Container( + child: child, + ), ); } diff --git a/lib/src/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/views/chats/chat_messages_components/message_context_menu.dart index 765323e..4d6a33f 100644 --- a/lib/src/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/views/chats/chat_messages_components/message_context_menu.dart @@ -4,10 +4,10 @@ import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart' as pb; import 'package:twonly/src/services/api/messages.dart'; @@ -16,6 +16,7 @@ import 'package:twonly/src/views/camera/image_editor/data/layer.dart'; import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart'; import 'package:twonly/src/views/chats/message_info.view.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; +import 'package:twonly/src/views/components/context_menu.component.dart'; class MessageContextMenu extends StatelessWidget { const MessageContextMenu({ @@ -23,27 +24,23 @@ class MessageContextMenu extends StatelessWidget { required this.group, required this.child, required this.onResponseTriggered, + required this.galleryItems, super.key, }); final Group group; final Widget child; final Message message; + final List galleryItems; final VoidCallback onResponseTriggered; @override Widget build(BuildContext context) { - return PieMenu( - onPressed: () => (), - onToggle: (menuOpen) async { - if (menuOpen) { - await HapticFeedback.heavyImpact(); - } - }, - actions: [ + return ContextMenu( + items: [ if (!message.isDeletedFromSender) - PieAction( - tooltip: Text(context.lang.react), - onSelect: () async { + ContextMenuItem( + title: context.lang.react, + onTap: () async { final layer = await showModalBottomSheet( context: context, backgroundColor: Colors.black, @@ -68,36 +65,38 @@ class MessageContextMenu extends StatelessWidget { null, ); }, - child: const FaIcon(FontAwesomeIcons.faceLaugh), + icon: FontAwesomeIcons.faceLaugh, ), if (!message.isDeletedFromSender) - PieAction( - tooltip: Text(context.lang.reply), - onSelect: onResponseTriggered, - child: const FaIcon(FontAwesomeIcons.reply), + ContextMenuItem( + title: context.lang.reply, + onTap: () async { + onResponseTriggered(); + }, + icon: FontAwesomeIcons.reply, ), if (!message.isDeletedFromSender && message.senderId == null && message.type == MessageType.text) - PieAction( - tooltip: Text(context.lang.edit), - onSelect: () async { + ContextMenuItem( + title: context.lang.edit, + onTap: () async { await editTextMessage(context, message); }, - child: const FaIcon(FontAwesomeIcons.pencil), + icon: FontAwesomeIcons.pencil, ), if (message.content != null) - PieAction( - tooltip: Text(context.lang.copy), - onSelect: () async { + ContextMenuItem( + title: context.lang.copy, + onTap: () async { await Clipboard.setData(ClipboardData(text: message.content!)); await HapticFeedback.heavyImpact(); }, - child: const FaIcon(FontAwesomeIcons.solidCopy), + icon: FontAwesomeIcons.solidCopy, ), - PieAction( - tooltip: Text(context.lang.delete), - onSelect: () async { + ContextMenuItem( + title: context.lang.delete, + onTap: () async { final delete = await showAlertDialog( context, context.lang.deleteTitle, @@ -130,12 +129,12 @@ class MessageContextMenu extends StatelessWidget { } } }, - child: const FaIcon(FontAwesomeIcons.trash), + icon: FontAwesomeIcons.trash, ), if (!message.isDeletedFromSender) - PieAction( - tooltip: Text(context.lang.info), - onSelect: () async { + ContextMenuItem( + title: context.lang.info, + onTap: () async { await Navigator.push( context, MaterialPageRoute( @@ -143,12 +142,13 @@ class MessageContextMenu extends StatelessWidget { return MessageInfoView( message: message, group: group, + galleryItems: galleryItems, ); }, ), ); }, - child: const FaIcon(FontAwesomeIcons.circleInfo), + icon: FontAwesomeIcons.circleInfo, ), ], child: child, diff --git a/lib/src/views/chats/message_info.view.dart b/lib/src/views/chats/message_info.view.dart index 36bb78f..e2b5acd 100644 --- a/lib/src/views/chats/message_info.view.dart +++ b/lib/src/views/chats/message_info.view.dart @@ -6,6 +6,7 @@ import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/chats/chat_messages_components/bottom_sheets/message_history.bottom_sheet.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart'; @@ -16,11 +17,13 @@ class MessageInfoView extends StatefulWidget { const MessageInfoView({ required this.message, required this.group, + required this.galleryItems, super.key, }); final Message message; final Group group; + final List galleryItems; @override State createState() => _MessageInfoViewState(); @@ -164,9 +167,24 @@ class _MessageInfoViewState extends State { child: ListView( children: [ const SizedBox(height: 20), - ChatListEntry( - group: widget.group, - message: widget.message, + Stack( + children: [ + ChatListEntry( + group: widget.group, + message: widget.message, + galleryItems: widget.galleryItems, + ), + Positioned.fill( + child: GestureDetector( + onTap: () { + // In case in ChatListEntry is a image, this prevents to open the image preview. + }, + child: Container( + color: Colors.transparent, + ), + ), + ), + ], ), Text( '${context.lang.sent}: ${friendlyDateTime(context, widget.message.createdAt)}', diff --git a/lib/src/views/chats/start_new_chat.view.dart b/lib/src/views/chats/start_new_chat.view.dart index 0aad997..c1670a6 100644 --- a/lib/src/views/chats/start_new_chat.view.dart +++ b/lib/src/views/chats/start_new_chat.view.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -74,34 +73,31 @@ class _StartNewChatView extends State { title: Text(context.lang.startNewChatTitle), ), body: SafeArea( - child: PieCanvas( - theme: getPieCanvasTheme(context), - child: Padding( - padding: - const EdgeInsets.only(bottom: 40, left: 10, top: 20, right: 10), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: TextField( - onChanged: (_) async { - await filterUsers(); - }, - controller: searchUserName, - decoration: getInputDecoration( - context, - context.lang.shareImageSearchAllContacts, - ), + child: Padding( + padding: + const EdgeInsets.only(bottom: 40, left: 10, top: 20, right: 10), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: TextField( + onChanged: (_) async { + await filterUsers(); + }, + controller: searchUserName, + decoration: getInputDecoration( + context, + context.lang.shareImageSearchAllContacts, ), ), - const SizedBox(height: 10), - Expanded( - child: UserList( - contacts, - ), + ), + const SizedBox(height: 10), + Expanded( + child: UserList( + contacts, ), - ], - ), + ), + ], ), ), ), diff --git a/lib/src/views/components/avatar_icon.component.dart b/lib/src/views/components/avatar_icon.component.dart index c05a9f6..8b9924a 100644 --- a/lib/src/views/components/avatar_icon.component.dart +++ b/lib/src/views/components/avatar_icon.component.dart @@ -26,7 +26,7 @@ class AvatarIcon extends StatefulWidget { } class _AvatarIconState extends State { - List avatarSVGs = []; + final List _avatarSVGs = []; @override void initState() { @@ -40,20 +40,20 @@ class _AvatarIconState extends State { await twonlyDB.groupsDao.getGroupContact(widget.group!.groupId); if (contacts.length == 1) { if (contacts.first.avatarSvgCompressed != null) { - avatarSVGs.add(getAvatarSvg(contacts.first.avatarSvgCompressed!)); + _avatarSVGs.add(getAvatarSvg(contacts.first.avatarSvgCompressed!)); } } else { for (final contact in contacts) { if (contact.avatarSvgCompressed != null) { - avatarSVGs.add(getAvatarSvg(contact.avatarSvgCompressed!)); + _avatarSVGs.add(getAvatarSvg(contact.avatarSvgCompressed!)); } } } // avatarSvg = group!.avatarSvg; } else if (widget.userData?.avatarSvg != null) { - avatarSVGs.add(widget.userData!.avatarSvg!); + _avatarSVGs.add(widget.userData!.avatarSvg!); } else if (widget.contact?.avatarSvgCompressed != null) { - avatarSVGs.add(getAvatarSvg(widget.contact!.avatarSvgCompressed!)); + _avatarSVGs.add(getAvatarSvg(widget.contact!.avatarSvgCompressed!)); } if (mounted) setState(() {}); } @@ -77,10 +77,10 @@ class _AvatarIconState extends State { width: proSize, color: widget.color, child: Center( - child: avatarSVGs.isEmpty + child: _avatarSVGs.isEmpty ? SvgPicture.asset('assets/images/default_avatar.svg') : SvgPicture.string( - avatarSVGs.first, + _avatarSVGs.first, errorBuilder: (context, error, stackTrace) { Log.error('$error'); return Container(); diff --git a/lib/src/views/components/context_menu.component.dart b/lib/src/views/components/context_menu.component.dart new file mode 100644 index 0000000..0cba731 --- /dev/null +++ b/lib/src/views/components/context_menu.component.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class ContextMenu extends StatefulWidget { + const ContextMenu({ + required this.child, + required this.items, + super.key, + }); + + final List items; + final Widget child; + + @override + State createState() => _ContextMenuState(); +} + +class _ContextMenuState extends State { + Offset? _tapPosition; + + Widget _getIcon(IconData icon) { + return Padding( + padding: const EdgeInsets.only(left: 12), + child: FaIcon( + icon, + size: 20, + ), + ); + } + + Future _showCustomMenu() async { + if (_tapPosition == null) { + return; + } + final overlay = Overlay.of(context).context.findRenderObject(); + if (overlay == null) { + return; + } + unawaited(HapticFeedback.heavyImpact()); + + await showMenu( + context: context, + menuPadding: EdgeInsetsGeometry.zero, + elevation: 1, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), // corner radius + ), + popUpAnimationStyle: const AnimationStyle( + duration: Duration.zero, + curve: Curves.fastOutSlowIn, + ), + items: >[ + ...widget.items.map( + (item) => PopupMenuItem( + padding: EdgeInsets.zero, + child: ListTile( + title: Text(item.title), + onTap: () async { + if (mounted) Navigator.pop(context); + await item.onTap(); + }, + leading: _getIcon(item.icon), + ), + ), + ) + ], + position: RelativeRect.fromRect( + _tapPosition! & const Size(40, 40), + Offset.zero & overlay.semanticBounds.size, + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPress: _showCustomMenu, + onTapDown: (TapDownDetails details) { + _tapPosition = details.globalPosition; + }, + child: widget.child, + ); + } +} + +class ContextMenuItem { + ContextMenuItem({ + required this.title, + required this.onTap, + required this.icon, + }); + final String title; + final Future Function() onTap; + final IconData icon; +} diff --git a/lib/src/views/components/group_context_menu.component.dart b/lib/src/views/components/group_context_menu.component.dart index 67605f7..b4d87f0 100644 --- a/lib/src/views/components/group_context_menu.component.dart +++ b/lib/src/views/components/group_context_menu.component.dart @@ -1,14 +1,13 @@ -import 'package:drift/drift.dart'; +import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/chats/chat_messages.view.dart'; +import 'package:twonly/src/views/components/context_menu.component.dart'; -class GroupContextMenu extends StatefulWidget { +class GroupContextMenu extends StatelessWidget { const GroupContextMenu({ required this.group, required this.child, @@ -17,80 +16,63 @@ class GroupContextMenu extends StatefulWidget { final Widget child; final Group group; - @override - State createState() => _GroupContextMenuState(); -} - -class _GroupContextMenuState extends State { @override Widget build(BuildContext context) { - return PieMenu( - onPressed: () => (), - onToggle: (menuOpen) async { - if (menuOpen) { - await HapticFeedback.heavyImpact(); - } - }, - actions: [ - if (!widget.group.archived) - PieAction( - tooltip: Text(context.lang.contextMenuArchiveUser), - onSelect: () async { + return ContextMenu( + items: [ + if (!group.archived) + ContextMenuItem( + title: context.lang.contextMenuArchiveUser, + onTap: () async { const update = GroupsCompanion(archived: Value(true)); if (context.mounted) { - await twonlyDB.groupsDao - .updateGroup(widget.group.groupId, update); + await twonlyDB.groupsDao.updateGroup(group.groupId, update); } }, - child: const FaIcon(FontAwesomeIcons.boxArchive), + icon: FontAwesomeIcons.boxArchive, ), - if (widget.group.archived) - PieAction( - tooltip: Text(context.lang.contextMenuUndoArchiveUser), - onSelect: () async { + if (group.archived) + ContextMenuItem( + title: context.lang.contextMenuUndoArchiveUser, + onTap: () async { const update = GroupsCompanion(archived: Value(false)); if (context.mounted) { - await twonlyDB.groupsDao - .updateGroup(widget.group.groupId, update); + await twonlyDB.groupsDao.updateGroup(group.groupId, update); } }, - child: const FaIcon(FontAwesomeIcons.boxOpen), + icon: FontAwesomeIcons.boxOpen, ), - PieAction( - tooltip: Text(context.lang.contextMenuOpenChat), - onSelect: () async { + ContextMenuItem( + title: context.lang.contextMenuOpenChat, + onTap: () async { await Navigator.push( context, MaterialPageRoute( builder: (context) { - return ChatMessagesView(widget.group); + return ChatMessagesView(group); }, ), ); }, - child: const FaIcon(FontAwesomeIcons.solidComments), + icon: FontAwesomeIcons.comments, ), - PieAction( - tooltip: Text( - widget.group.pinned + if (!group.archived) + ContextMenuItem( + title: group.pinned ? context.lang.contextMenuUnpin : context.lang.contextMenuPin, - ), - onSelect: () async { - final update = GroupsCompanion(pinned: Value(!widget.group.pinned)); - if (context.mounted) { - await twonlyDB.groupsDao - .updateGroup(widget.group.groupId, update); - } - }, - child: FaIcon( - widget.group.pinned + onTap: () async { + final update = GroupsCompanion(pinned: Value(!group.pinned)); + if (context.mounted) { + await twonlyDB.groupsDao.updateGroup(group.groupId, update); + } + }, + icon: group.pinned ? FontAwesomeIcons.thumbtackSlash : FontAwesomeIcons.thumbtack, ), - ), ], - child: widget.child, + child: child, ); } } diff --git a/lib/src/views/components/user_context_menu.component.dart b/lib/src/views/components/user_context_menu.component.dart index 2d0f004..7151848 100644 --- a/lib/src/views/components/user_context_menu.component.dart +++ b/lib/src/views/components/user_context_menu.component.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/components/context_menu.component.dart'; import 'package:twonly/src/views/contact/contact.view.dart'; -class UserContextMenu extends StatefulWidget { +class UserContextMenu extends StatelessWidget { const UserContextMenu({ required this.contact, required this.child, @@ -14,32 +14,26 @@ class UserContextMenu extends StatefulWidget { final Widget child; final Contact contact; - @override - State createState() => _UserContextMenuBlocked(); -} - -class _UserContextMenuBlocked extends State { @override Widget build(BuildContext context) { - return PieMenu( - onPressed: () => (), - actions: [ - PieAction( - tooltip: Text(context.lang.contextMenuUserProfile), - onSelect: () async { + return ContextMenu( + items: [ + ContextMenuItem( + title: context.lang.contextMenuUserProfile, + onTap: () async { await Navigator.push( context, MaterialPageRoute( builder: (context) { - return ContactView(widget.contact.userId); + return ContactView(contact.userId); }, ), ); }, - child: const FaIcon(FontAwesomeIcons.user), + icon: FontAwesomeIcons.user, ), ], - child: widget.child, + child: child, ); } } diff --git a/lib/src/views/contact/contact.view.dart b/lib/src/views/contact/contact.view.dart index c38d53e..2df4ce8 100644 --- a/lib/src/views/contact/contact.view.dart +++ b/lib/src/views/contact/contact.view.dart @@ -4,7 +4,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/utils.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart'; @@ -29,8 +28,14 @@ class _ContactViewState extends State { context.lang.contactRemoveBody, ); if (remove) { - // trigger deletion for the other user... - await rejectAndHideContact(contact.userId); + await twonlyDB.contactsDao.updateContact( + contact.userId, + const ContactsCompanion( + accepted: Value(false), + requested: Value(false), + deletedByUser: Value(true), + ), + ); if (mounted) { Navigator.popUntil(context, (route) => route.isFirst); } @@ -192,13 +197,13 @@ class _ContactViewState extends State { text: context.lang.contactBlock, onTap: () => handleUserBlockRequest(contact), ), - BetterListTile( - icon: FontAwesomeIcons.userMinus, - iconSize: 16, - color: Colors.red, - text: context.lang.contactRemove, - onTap: () => handleUserRemoveRequest(contact), - ), + // BetterListTile( + // icon: FontAwesomeIcons.userMinus, + // iconSize: 16, + // color: Colors.red, + // text: context.lang.contactRemove, + // onTap: () => handleUserRemoveRequest(contact), + // ), ], ); }, diff --git a/lib/src/views/home.view.dart b/lib/src/views/home.view.dart index 0c24c4a..d7e3bd8 100644 --- a/lib/src/views/home.view.dart +++ b/lib/src/views/home.view.dart @@ -3,7 +3,6 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:screenshot/screenshot.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -155,93 +154,90 @@ class HomeViewState extends State { @override Widget build(BuildContext context) { - return PieCanvas( - theme: getPieCanvasTheme(context), - child: Scaffold( - body: GestureDetector( - onDoubleTap: offsetRatio == 0 ? toggleSelectedCamera : null, - child: Stack( - children: [ - HomeViewCameraPreview( - controller: cameraController, - screenshotController: screenshotController, - ), - Shade( - opacity: offsetRatio, - ), - NotificationListener( - onNotification: onPageView, - child: Positioned.fill( - child: PageView( - controller: homeViewPageController, - onPageChanged: (index) { - setState(() { - activePageIdx = index; - }); - }, - children: [ - const ChatListView(), - Container(), - const MemoriesView(), - ], - ), + return Scaffold( + body: GestureDetector( + onDoubleTap: offsetRatio == 0 ? toggleSelectedCamera : null, + child: Stack( + children: [ + HomeViewCameraPreview( + controller: cameraController, + screenshotController: screenshotController, + ), + Shade( + opacity: offsetRatio, + ), + NotificationListener( + onNotification: onPageView, + child: Positioned.fill( + child: PageView( + controller: homeViewPageController, + onPageChanged: (index) { + setState(() { + activePageIdx = index; + }); + }, + children: [ + const ChatListView(), + Container(), + const MemoriesView(), + ], ), ), - Positioned( - left: 0, - top: 0, - right: 0, - bottom: (offsetRatio > 0.25) - ? MediaQuery.sizeOf(context).height * 2 - : 0, - child: Opacity( - opacity: 1 - (offsetRatio * 4) % 1, - child: CameraPreviewControllerView( - cameraController: cameraController, - screenshotController: screenshotController, - selectedCameraDetails: selectedCameraDetails, - selectCamera: selectCamera, - ), + ), + Positioned( + left: 0, + top: 0, + right: 0, + bottom: (offsetRatio > 0.25) + ? MediaQuery.sizeOf(context).height * 2 + : 0, + child: Opacity( + opacity: 1 - (offsetRatio * 4) % 1, + child: CameraPreviewControllerView( + cameraController: cameraController, + screenshotController: screenshotController, + selectedCameraDetails: selectedCameraDetails, + selectCamera: selectCamera, ), ), - ], - ), - ), - bottomNavigationBar: BottomNavigationBar( - showSelectedLabels: false, - showUnselectedLabels: false, - unselectedIconTheme: IconThemeData( - color: Theme.of(context).colorScheme.inverseSurface.withAlpha(150), - ), - selectedIconTheme: IconThemeData( - color: Theme.of(context).colorScheme.inverseSurface, - ), - items: const [ - BottomNavigationBarItem( - icon: FaIcon(FontAwesomeIcons.solidComments), - label: '', - ), - BottomNavigationBarItem( - icon: FaIcon(FontAwesomeIcons.camera), - label: '', - ), - BottomNavigationBarItem( - icon: FaIcon(FontAwesomeIcons.photoFilm), - label: '', ), ], - onTap: (int index) async { - activePageIdx = index; - await homeViewPageController.animateToPage( - index, - duration: const Duration(milliseconds: 100), - curve: Curves.bounceIn, - ); - if (mounted) setState(() {}); - }, - currentIndex: activePageIdx, ), ), + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + unselectedIconTheme: IconThemeData( + color: Theme.of(context).colorScheme.inverseSurface.withAlpha(150), + ), + selectedIconTheme: IconThemeData( + color: Theme.of(context).colorScheme.inverseSurface, + ), + items: const [ + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.solidComments), + label: '', + ), + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.camera), + label: '', + ), + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.photoFilm), + label: '', + ), + ], + onTap: (int index) async { + activePageIdx = index; + await homeViewPageController.animateToPage( + index, + duration: const Duration(milliseconds: 100), + curve: Curves.bounceIn, + ); + if (mounted) setState(() {}); + }, + currentIndex: activePageIdx, + ), ); } } diff --git a/lib/src/views/settings/privacy_view_block.users.dart b/lib/src/views/settings/privacy_view_block.users.dart index 8b383f2..92066d2 100644 --- a/lib/src/views/settings/privacy_view_block.users.dart +++ b/lib/src/views/settings/privacy_view_block.users.dart @@ -1,6 +1,5 @@ import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; -import 'package:pie_menu/pie_menu.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -32,53 +31,50 @@ class _PrivacyViewBlockUsers extends State { appBar: AppBar( title: Text(context.lang.settingsPrivacyBlockUsers), ), - body: PieCanvas( - theme: getPieCanvasTheme(context), - child: Padding( - padding: - const EdgeInsets.only(bottom: 20, left: 10, top: 20, right: 10), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: TextField( - onChanged: (value) => setState(() { - filter = value; - }), - decoration: getInputDecoration( - context, - context.lang.searchUsernameInput, - ), + body: Padding( + padding: + const EdgeInsets.only(bottom: 20, left: 10, top: 20, right: 10), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: TextField( + onChanged: (value) => setState(() { + filter = value; + }), + decoration: getInputDecoration( + context, + context.lang.searchUsernameInput, ), ), - const SizedBox(height: 20), - Text( - context.lang.settingsPrivacyBlockUsersDesc, - textAlign: TextAlign.center, - ), - const SizedBox(height: 30), - Expanded( - child: StreamBuilder( - stream: allUsers, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Container(); - } + ), + const SizedBox(height: 20), + Text( + context.lang.settingsPrivacyBlockUsersDesc, + textAlign: TextAlign.center, + ), + const SizedBox(height: 30), + Expanded( + child: StreamBuilder( + stream: allUsers, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Container(); + } - final filteredContacts = snapshot.data!.where((contact) { - return getContactDisplayName(contact) - .toLowerCase() - .contains(filter.toLowerCase()); - }).toList(); + final filteredContacts = snapshot.data!.where((contact) { + return getContactDisplayName(contact) + .toLowerCase() + .contains(filter.toLowerCase()); + }).toList(); - return UserList( - List.from(filteredContacts), - ); - }, - ), + return UserList( + List.from(filteredContacts), + ); + }, ), - ], - ), + ), + ], ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 99827f9..a1584b0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1278,15 +1278,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.0" - pie_menu: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: e1ae0b2dabdfa9ad204b2cf93c48a5962e243c6c - url: "https://github.com/otsmr/flutter-pie-menu.git" - source: git - version: "3.3.2" platform: dependency: transitive description: @@ -1816,10 +1807,10 @@ packages: dependency: transitive description: name: video_player_platform_interface - sha256: "9e372520573311055cb353b9a0da1c9d72b094b7ba01b8ecc66f28473553793b" + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.0" video_player_web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d01d5d0..f9a646d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,9 +59,6 @@ dependencies: path_provider: ^2.1.5 permission_handler: ^12.0.0+1 photo_view: ^0.15.0 - pie_menu: - git: - url: https://github.com/otsmr/flutter-pie-menu.git protobuf: ^4.0.0 provider: ^6.1.2 restart_app: ^1.3.2