diff --git a/lib/src/components/message_send_state_icon.dart b/lib/src/components/message_send_state_icon.dart index 38a5064..aba2934 100644 --- a/lib/src/components/message_send_state_icon.dart +++ b/lib/src/components/message_send_state_icon.dart @@ -24,12 +24,7 @@ class MessageSendStateIcon extends StatelessWidget { Widget icon = Placeholder(); String text = ""; - Color color = Theme.of(context).colorScheme.primary; - if (kind == MessageKind.textMessage) { - color = Colors.lightBlue; - } else if (kind == MessageKind.video) { - color = Colors.deepPurple; - } + Color color = kind.getColor(Theme.of(context).colorScheme.primary); switch (state) { case MessageSendState.receivedOpened: diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index c866ed3..cccac11 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -20,8 +20,10 @@ "searchUsernameNotFound": "Username not found", "searchUsernameNewFollowerTitle": "Follow requests", "searchUsernameQrCodeBtn": "Scan QR code", - "chatListViewSearchUserNameBtn": "Add user", + "chatListViewSearchUserNameBtn": "Add your first twonly contact!", "chatListViewSendFirstTwonly": "Send your first twonly!", + "chatListDetailInput": "Type a message", + "chatListDetailTitle": "Your chat with {username}", "searchUsernameNotFoundLong": "\"{username}\" is not a twonly user. Please check the username and try again.", "errorUnknown": "An unexpected error has occurred. Please try again later.", "errorBadRequest": "The request could not be understood by the server due to malformed syntax. Please check your input and try again.", diff --git a/lib/src/model/json/message.dart b/lib/src/model/json/message.dart index 05e46e4..90854f0 100644 --- a/lib/src/model/json/message.dart +++ b/lib/src/model/json/message.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:twonly/src/utils/json.dart'; part 'message.g.dart'; @@ -9,6 +10,7 @@ enum MessageKind { contactRequest, rejectRequest, acceptRequest, + opened, ack } @@ -24,6 +26,16 @@ extension MessageKindExtension on MessageKind { static MessageKind fromIndex(int index) { return MessageKind.values[index]; } + + Color getColor(Color primary) { + Color color = primary; + if (this == MessageKind.textMessage) { + color = Colors.lightBlue; + } else if (this == MessageKind.video) { + color = Colors.deepPurple; + } + return color; + } } // so _$MessageKindEnumMap gets generated diff --git a/lib/src/model/json/message.g.dart b/lib/src/model/json/message.g.dart index 2e720b8..71a0ae5 100644 --- a/lib/src/model/json/message.g.dart +++ b/lib/src/model/json/message.g.dart @@ -21,6 +21,7 @@ const _$MessageKindEnumMap = { MessageKind.contactRequest: 'contactRequest', MessageKind.rejectRequest: 'rejectRequest', MessageKind.acceptRequest: 'acceptRequest', + MessageKind.opened: 'opened', MessageKind.ack: 'ack', }; diff --git a/lib/src/model/messages_model.dart b/lib/src/model/messages_model.dart index 45ae7cd..747fa7f 100644 --- a/lib/src/model/messages_model.dart +++ b/lib/src/model/messages_model.dart @@ -4,6 +4,7 @@ import 'package:cv/cv.dart'; import 'package:logging/logging.dart'; import 'package:twonly/main.dart'; import 'package:twonly/src/app.dart'; +import 'package:twonly/src/components/message_send_state_icon.dart'; import 'package:twonly/src/model/json/message.dart'; import 'package:twonly/src/providers/api/api.dart'; @@ -37,6 +38,30 @@ class DbMessage { if (messageOtherId == null) return false; return messageKind == MessageKind.image || messageKind == MessageKind.video; } + + MessageSendState getSendState() { + MessageSendState state; + if (!messageAcknowledgeByServer) { + state = MessageSendState.sending; + } else { + if (messageOtherId == null) { + // message send + if (messageOpenedAt == null) { + state = MessageSendState.send; + } else { + state = MessageSendState.sendOpened; + } + } else { + // message received + if (messageOpenedAt == null) { + state = MessageSendState.received; + } else { + state = MessageSendState.receivedOpened; + } + } + } + return state; + } } class DbMessages extends CvModelBase { @@ -179,6 +204,12 @@ class DbMessages extends CvModelBase { List messages = await convertToDbMessage(rows); + // check if you received a message which the user has not already opened + List receivedByOther = messages + .where((c) => c.messageOtherId != null && c.messageOpenedAt == null) + .toList(); + if (receivedByOther.isNotEmpty) return receivedByOther[0]; + // check if there is a message which was not ack by the server List notAckByServer = messages.where((c) => !c.messageAcknowledgeByServer).toList(); @@ -198,7 +229,7 @@ class DbMessages extends CvModelBase { await dbProvider.db!.update( tableName, data, - where: "$messageId = ?", + where: "$columnMessageId = ?", whereArgs: [messageId], ); int? fromUserId = await getFromUserIdByMessageId(messageId); @@ -207,6 +238,18 @@ class DbMessages extends CvModelBase { } } + // this ensures that the message id can be spoofed by another person + static Future _updateByMessageIdOther( + int fromUserId, int messageId, Map data) async { + await dbProvider.db!.update( + tableName, + data, + where: "$columnMessageId = ? AND $columnOtherUserId = ?", + whereArgs: [messageId, fromUserId], + ); + globalCallBackOnMessageChange(fromUserId); + } + static Future userOpenedMessage(int messageId) async { Map data = { columnMessageOpenedAt: DateTime.now().toIso8601String(), @@ -214,6 +257,14 @@ class DbMessages extends CvModelBase { await _updateByMessageId(messageId, data); } + static Future userOpenedMessageOtherUser( + int fromUserId, int messageId, DateTime openedAt) async { + Map data = { + columnMessageOpenedAt: openedAt.toIso8601String(), + }; + await _updateByMessageIdOther(fromUserId, messageId, data); + } + static Future acknowledgeMessageByServer(int messageId) async { Map data = { columnMessageAcknowledgeByServer: 1, diff --git a/lib/src/providers/api/api.dart b/lib/src/providers/api/api.dart index 067243c..c60a437 100644 --- a/lib/src/providers/api/api.dart +++ b/lib/src/providers/api/api.dart @@ -41,6 +41,24 @@ Future encryptAndSendMessage(Int64 userId, Message msg) async { return resp; } +Future sendTextMessage(Int64 target, String message) async { + MessageContent content = MessageContent(text: message, downloadToken: null); + + int? messageId = await DbMessages.insertMyMessage( + target.toInt(), MessageKind.textMessage, + jsonContent: jsonEncode(content.toJson())); + if (messageId == null) return; + + Message msg = Message( + kind: MessageKind.textMessage, + messageId: messageId, + content: content, + timestamp: DateTime.now(), + ); + + await encryptAndSendMessage(target, msg); +} + Future sendImageToSingleTarget(Int64 target, Uint8List imageBytes) async { int? messageId = await DbMessages.insertMyMessage(target.toInt(), MessageKind.image); @@ -118,15 +136,28 @@ Future tryDownloadMedia(List imageToken, {bool force = false}) async { apiProvider.triggerDownload(imageToken); } +Future userOpenedMessage(int fromUserId, int messageId) async { + await DbMessages.userOpenedMessage(messageId); + + encryptAndSendMessage( + Int64(fromUserId), + Message( + kind: MessageKind.opened, + messageId: messageId, + timestamp: DateTime.now(), + ), + ); +} + Future getDownloadedMedia( List mediaToken, int messageId) async { final box = await getMediaStorage(); Uint8List? media = box.get("${mediaToken}_downloaded"); - // box.delete(mediaToken.toString()); - // box.delete("${mediaToken}_downloaded"); - // box.delete("${mediaToken}_fromUserId"); - // await DbMessages.userOpenedMessage(messageId); - + int fromUserId = box.get("${mediaToken}_fromUserId"); + await userOpenedMessage(fromUserId, messageId); + box.delete(mediaToken.toString()); + box.put("${mediaToken}_downloaded", "deleted"); + box.delete("${mediaToken}_fromUserId"); return media; } diff --git a/lib/src/providers/api/server_messages.dart b/lib/src/providers/api/server_messages.dart index 6ebc449..f411cd9 100644 --- a/lib/src/providers/api/server_messages.dart +++ b/lib/src/providers/api/server_messages.dart @@ -101,6 +101,13 @@ Future handleNewMessage( utf8.decode(name), fromUserId.toInt(), true); } break; + case MessageKind.opened: + await DbMessages.userOpenedMessageOtherUser( + fromUserId.toInt(), + message.messageId!, + message.timestamp, + ); + break; case MessageKind.rejectRequest: DbContacts.deleteUser(fromUserId.toInt()); break; diff --git a/lib/src/providers/api_provider.dart b/lib/src/providers/api_provider.dart index d5729d0..e1e16e9 100644 --- a/lib/src/providers/api_provider.dart +++ b/lib/src/providers/api_provider.dart @@ -179,7 +179,7 @@ class ApiProvider { final result = await _sendRequestV0(req); if (result.isError) { - log.shout(result); + log.shout("Error auth", result); return; } @@ -203,7 +203,7 @@ class ApiProvider { final result2 = await _sendRequestV0(req2); if (result2.isError) { - log.shout(result2); + log.shout("send request failed: ${result2.error}"); return; } diff --git a/lib/src/providers/messages_change_provider.dart b/lib/src/providers/messages_change_provider.dart index 65abdfa..838e089 100644 --- a/lib/src/providers/messages_change_provider.dart +++ b/lib/src/providers/messages_change_provider.dart @@ -6,7 +6,6 @@ import 'package:twonly/src/model/messages_model.dart'; /// for every contact. class MessagesChangeProvider with ChangeNotifier, DiagnosticableTreeMixin { final Map _lastMessage = {}; - Map get lastMessage => _lastMessage; void updateLastMessageFor(int targetUserId) async { diff --git a/lib/src/views/camera_preview_view.dart b/lib/src/views/camera_preview_view.dart index 9531828..510c214 100644 --- a/lib/src/views/camera_preview_view.dart +++ b/lib/src/views/camera_preview_view.dart @@ -141,6 +141,10 @@ class _CameraPreviewViewState extends State { }); } }, + onDoubleTap: () async { + cameraState.switchCameraSensor( + aspectRatio: CameraAspectRatios.ratio_16_9); + }, ), ), Positioned( diff --git a/lib/src/views/chat_item_details_view.dart b/lib/src/views/chat_item_details_view.dart index 21aa1d8..5eaf53d 100644 --- a/lib/src/views/chat_item_details_view.dart +++ b/lib/src/views/chat_item_details_view.dart @@ -1,16 +1,28 @@ +import 'package:cv/cv.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/src/components/message_send_state_icon.dart'; import 'package:twonly/src/model/contacts_model.dart'; import 'package:twonly/src/model/json/message.dart'; import 'package:twonly/src/model/messages_model.dart'; +import 'package:twonly/src/providers/api/api.dart'; +import 'package:twonly/src/providers/messages_change_provider.dart'; +import 'package:twonly/src/views/media_viewer_view.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class ChatListEntry extends StatelessWidget { - const ChatListEntry(this.message, {super.key}); + const ChatListEntry(this.message, this.user, this.lastMessageFromSameUser, + {super.key}); final DbMessage message; + final Contact user; + final bool lastMessageFromSameUser; @override Widget build(BuildContext context) { bool right = message.messageOtherId == null; + MessageSendState state = message.getSendState(); + Widget child = Container(); switch (message.messageKind) { @@ -38,13 +50,52 @@ class ChatListEntry extends StatelessWidget { ), ); break; + case MessageKind.image: + Color color = + message.messageKind.getColor(Theme.of(context).colorScheme.primary); + child = GestureDetector( + onTap: () { + if (state == MessageSendState.received) { + if (message.isDownloaded) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) { + return MediaViewerView(user, message); + }), + ); + } else { + List token = message.messageContent!.downloadToken!; + tryDownloadMedia(token, force: true); + } + } + }, + child: Container( + padding: EdgeInsets.all(10), + width: 200, + decoration: BoxDecoration( + border: Border.all( + color: color, // Set the background color + width: 1.0, // Set the border width here + ), + borderRadius: BorderRadius.circular(12.0), // Set border radius + ), + child: MessageSendStateIcon( + state, + message.isDownloaded, + message.messageKind, + ), + ), + ); default: return Container(); } - return Align( alignment: right ? Alignment.centerRight : Alignment.centerLeft, - child: Padding(padding: EdgeInsets.all(10), child: child), + child: Padding( + padding: lastMessageFromSameUser + ? EdgeInsets.only(top: 5, bottom: 0, right: 10, left: 10) + : EdgeInsets.all(10), + child: child), ); } } @@ -61,6 +112,7 @@ class ChatItemDetailsView extends StatefulWidget { class _ChatItemDetailsViewState extends State { List _messages = []; + final TextEditingController newMessageController = TextEditingController(); @override void initState() { @@ -73,8 +125,23 @@ class _ChatItemDetailsViewState extends State { await DbMessages.getAllMessagesForUser(widget.user.userId.toInt()); } + Future _sendMessage() async { + String text = newMessageController.text; + if (text == "") return; + sendTextMessage(widget.user.userId, newMessageController.text); + newMessageController.clear(); + } + @override Widget build(BuildContext context) { + final messages = context.watch().lastMessage; + if (messages.containsKey(widget.user.userId.toInt()) && + _messages.isNotEmpty) { + final lastMessage = messages[widget.user.userId.toInt()]; + if (lastMessage!.messageId != _messages[0].messageId) { + _loadAsync(); + } + } // messages = messages.reversed.toList(); return Scaffold( appBar: AppBar( @@ -87,7 +154,16 @@ class _ChatItemDetailsViewState extends State { itemCount: _messages.length, // Number of items in the list reverse: true, itemBuilder: (context, i) { - return ChatListEntry(_messages[i]); + bool lastMessageFromSameUser = false; + if (i > 0) { + lastMessageFromSameUser = + (_messages[i - 1].messageOtherId == null && + _messages[i].messageOtherId == null) || + (_messages[i - 1].messageOtherId != null && + _messages[i].messageOtherId != null); + } + return ChatListEntry( + _messages[i], widget.user, lastMessageFromSameUser); }, ), ), @@ -98,9 +174,13 @@ class _ChatItemDetailsViewState extends State { children: [ Expanded( child: TextField( - // controller: _controller, + controller: newMessageController, + onSubmitted: (_) { + _sendMessage(); + }, decoration: InputDecoration( - hintText: 'Type a message', + hintText: + AppLocalizations.of(context)!.chatListDetailInput, contentPadding: EdgeInsets.symmetric(horizontal: 10) // border: OutlineInputBorder(), ), @@ -110,31 +190,12 @@ class _ChatItemDetailsViewState extends State { IconButton( icon: FaIcon(FontAwesomeIcons.solidPaperPlane), onPressed: () { - // Handle send action + _sendMessage(); }, ), ], ), ), - // Container( - // child: Row(children: [ - // Padding( - // padding: const EdgeInsets.only(bottom: 40, left: 10, right: 10), - // child: TextField( - // decoration: InputDecoration( - // // border: OutlineInputBorder(), - // hintText: 'Enter your message', - // ), - // ), - // ), - // IconButton( - // icon: FaIcon(FontAwesomeIcons.solidPaperPlane), - // onPressed: () { - // // Handle send action - // }, - // ), - // ]), - // ), ], ), ); diff --git a/lib/src/views/chat_list_view.dart b/lib/src/views/chat_list_view.dart index eb8a33e..ed81dec 100644 --- a/lib/src/views/chat_list_view.dart +++ b/lib/src/views/chat_list_view.dart @@ -44,7 +44,11 @@ class _ChatListViewState extends State { Map lastMessages = context.watch().lastMessage; - List allUsers = context.read().allContacts; + List allUsers = context + .read() + .allContacts + .where((c) => c.accepted) + .toList(); List activeUsers = allUsers .where((x) => lastMessages.containsKey(x.userId.toInt())) @@ -80,11 +84,11 @@ class _ChatListViewState extends State { child: Padding( padding: const EdgeInsets.all(10), child: OutlinedButton.icon( - icon: Icon((allUsers.isEmpty) + icon: Icon((activeUsers.isEmpty) ? Icons.person_add : Icons.camera_alt), onPressed: () { - (allUsers.isEmpty) + (activeUsers.isEmpty) ? Navigator.push( context, MaterialPageRoute( @@ -93,7 +97,7 @@ class _ChatListViewState extends State { ) : globalUpdateOfHomeViewPageIndex(1); }, - label: Text((allUsers.isEmpty) + label: Text((activeUsers.isEmpty) ? AppLocalizations.of(context)! .chatListViewSearchUserNameBtn : AppLocalizations.of(context)! @@ -140,30 +144,11 @@ class _UserListItem extends State { @override Widget build(BuildContext context) { - MessageSendState state; int lastMessageInSeconds = DateTime.now() .difference(widget.lastMessage.sendOrReceivedAt) .inSeconds; - if (!widget.lastMessage.messageAcknowledgeByServer) { - state = MessageSendState.sending; - } else { - if (widget.lastMessage.messageOtherId == null) { - // message send - if (widget.lastMessage.messageOpenedAt == null) { - state = MessageSendState.send; - } else { - state = MessageSendState.sendOpened; - } - } else { - // message received - if (widget.lastMessage.messageOpenedAt == null) { - state = MessageSendState.received; - } else { - state = MessageSendState.receivedOpened; - } - } - } + MessageSendState state = widget.lastMessage.getSendState(); return UserContextMenu( user: widget.user, diff --git a/lib/src/views/media_viewer_view.dart b/lib/src/views/media_viewer_view.dart index 121685e..a0edabf 100644 --- a/lib/src/views/media_viewer_view.dart +++ b/lib/src/views/media_viewer_view.dart @@ -27,9 +27,7 @@ class _MediaViewerViewState extends State { Future _initAsync() async { List token = widget.message.messageContent!.downloadToken!; - _imageByte = await getDownloadedMedia(token, widget.message.messageId); - print(_imageByte); setState(() {}); } @@ -53,7 +51,7 @@ class _MediaViewerViewState extends State { _imageByte!, fit: BoxFit.contain, ) - : CircularProgressIndicator()), + : Container()), ), ), _imageByte != null diff --git a/lib/src/views/search_username_view.dart b/lib/src/views/search_username_view.dart index 3dddb0d..908e00c 100644 --- a/lib/src/views/search_username_view.dart +++ b/lib/src/views/search_username_view.dart @@ -84,14 +84,16 @@ class _SearchUsernameView extends State { child: Column( children: [ Padding( - padding: EdgeInsets.symmetric(horizontal: 10), - child: TextField( - onSubmitted: (_) { - _addNewUser(context); - }, - controller: searchUserName, - decoration: getInputDecoration( - AppLocalizations.of(context)!.searchUsernameInput))), + padding: EdgeInsets.symmetric(horizontal: 10), + child: TextField( + onSubmitted: (_) { + _addNewUser(context); + }, + controller: searchUserName, + decoration: getInputDecoration( + AppLocalizations.of(context)!.searchUsernameInput), + ), + ), const SizedBox(height: 20), OutlinedButton.icon( icon: Icon(Icons.qr_code),