sending text messages kinda works

This commit is contained in:
otsmr 2025-01-30 12:09:05 +01:00
parent f866e4315e
commit e0d420b78d
14 changed files with 225 additions and 77 deletions

View file

@ -24,12 +24,7 @@ class MessageSendStateIcon extends StatelessWidget {
Widget icon = Placeholder(); Widget icon = Placeholder();
String text = ""; String text = "";
Color color = Theme.of(context).colorScheme.primary; Color color = kind.getColor(Theme.of(context).colorScheme.primary);
if (kind == MessageKind.textMessage) {
color = Colors.lightBlue;
} else if (kind == MessageKind.video) {
color = Colors.deepPurple;
}
switch (state) { switch (state) {
case MessageSendState.receivedOpened: case MessageSendState.receivedOpened:

View file

@ -20,8 +20,10 @@
"searchUsernameNotFound": "Username not found", "searchUsernameNotFound": "Username not found",
"searchUsernameNewFollowerTitle": "Follow requests", "searchUsernameNewFollowerTitle": "Follow requests",
"searchUsernameQrCodeBtn": "Scan QR code", "searchUsernameQrCodeBtn": "Scan QR code",
"chatListViewSearchUserNameBtn": "Add user", "chatListViewSearchUserNameBtn": "Add your first twonly contact!",
"chatListViewSendFirstTwonly": "Send your first twonly!", "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.", "searchUsernameNotFoundLong": "\"{username}\" is not a twonly user. Please check the username and try again.",
"errorUnknown": "An unexpected error has occurred. Please try again later.", "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.", "errorBadRequest": "The request could not be understood by the server due to malformed syntax. Please check your input and try again.",

View file

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:twonly/src/utils/json.dart'; import 'package:twonly/src/utils/json.dart';
part 'message.g.dart'; part 'message.g.dart';
@ -9,6 +10,7 @@ enum MessageKind {
contactRequest, contactRequest,
rejectRequest, rejectRequest,
acceptRequest, acceptRequest,
opened,
ack ack
} }
@ -24,6 +26,16 @@ extension MessageKindExtension on MessageKind {
static MessageKind fromIndex(int index) { static MessageKind fromIndex(int index) {
return MessageKind.values[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 // so _$MessageKindEnumMap gets generated

View file

@ -21,6 +21,7 @@ const _$MessageKindEnumMap = {
MessageKind.contactRequest: 'contactRequest', MessageKind.contactRequest: 'contactRequest',
MessageKind.rejectRequest: 'rejectRequest', MessageKind.rejectRequest: 'rejectRequest',
MessageKind.acceptRequest: 'acceptRequest', MessageKind.acceptRequest: 'acceptRequest',
MessageKind.opened: 'opened',
MessageKind.ack: 'ack', MessageKind.ack: 'ack',
}; };

View file

@ -4,6 +4,7 @@ import 'package:cv/cv.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:twonly/main.dart'; import 'package:twonly/main.dart';
import 'package:twonly/src/app.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/model/json/message.dart';
import 'package:twonly/src/providers/api/api.dart'; import 'package:twonly/src/providers/api/api.dart';
@ -37,6 +38,30 @@ class DbMessage {
if (messageOtherId == null) return false; if (messageOtherId == null) return false;
return messageKind == MessageKind.image || messageKind == MessageKind.video; 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 { class DbMessages extends CvModelBase {
@ -179,6 +204,12 @@ class DbMessages extends CvModelBase {
List<DbMessage> messages = await convertToDbMessage(rows); List<DbMessage> messages = await convertToDbMessage(rows);
// check if you received a message which the user has not already opened
List<DbMessage> 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 // check if there is a message which was not ack by the server
List<DbMessage> notAckByServer = List<DbMessage> notAckByServer =
messages.where((c) => !c.messageAcknowledgeByServer).toList(); messages.where((c) => !c.messageAcknowledgeByServer).toList();
@ -198,7 +229,7 @@ class DbMessages extends CvModelBase {
await dbProvider.db!.update( await dbProvider.db!.update(
tableName, tableName,
data, data,
where: "$messageId = ?", where: "$columnMessageId = ?",
whereArgs: [messageId], whereArgs: [messageId],
); );
int? fromUserId = await getFromUserIdByMessageId(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<String, dynamic> data) async {
await dbProvider.db!.update(
tableName,
data,
where: "$columnMessageId = ? AND $columnOtherUserId = ?",
whereArgs: [messageId, fromUserId],
);
globalCallBackOnMessageChange(fromUserId);
}
static Future userOpenedMessage(int messageId) async { static Future userOpenedMessage(int messageId) async {
Map<String, dynamic> data = { Map<String, dynamic> data = {
columnMessageOpenedAt: DateTime.now().toIso8601String(), columnMessageOpenedAt: DateTime.now().toIso8601String(),
@ -214,6 +257,14 @@ class DbMessages extends CvModelBase {
await _updateByMessageId(messageId, data); await _updateByMessageId(messageId, data);
} }
static Future userOpenedMessageOtherUser(
int fromUserId, int messageId, DateTime openedAt) async {
Map<String, dynamic> data = {
columnMessageOpenedAt: openedAt.toIso8601String(),
};
await _updateByMessageIdOther(fromUserId, messageId, data);
}
static Future acknowledgeMessageByServer(int messageId) async { static Future acknowledgeMessageByServer(int messageId) async {
Map<String, dynamic> data = { Map<String, dynamic> data = {
columnMessageAcknowledgeByServer: 1, columnMessageAcknowledgeByServer: 1,

View file

@ -41,6 +41,24 @@ Future<Result> encryptAndSendMessage(Int64 userId, Message msg) async {
return resp; 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 { Future sendImageToSingleTarget(Int64 target, Uint8List imageBytes) async {
int? messageId = int? messageId =
await DbMessages.insertMyMessage(target.toInt(), MessageKind.image); await DbMessages.insertMyMessage(target.toInt(), MessageKind.image);
@ -118,15 +136,28 @@ Future tryDownloadMedia(List<int> imageToken, {bool force = false}) async {
apiProvider.triggerDownload(imageToken); 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<Uint8List?> getDownloadedMedia( Future<Uint8List?> getDownloadedMedia(
List<int> mediaToken, int messageId) async { List<int> mediaToken, int messageId) async {
final box = await getMediaStorage(); final box = await getMediaStorage();
Uint8List? media = box.get("${mediaToken}_downloaded"); Uint8List? media = box.get("${mediaToken}_downloaded");
// box.delete(mediaToken.toString()); int fromUserId = box.get("${mediaToken}_fromUserId");
// box.delete("${mediaToken}_downloaded"); await userOpenedMessage(fromUserId, messageId);
// box.delete("${mediaToken}_fromUserId"); box.delete(mediaToken.toString());
// await DbMessages.userOpenedMessage(messageId); box.put("${mediaToken}_downloaded", "deleted");
box.delete("${mediaToken}_fromUserId");
return media; return media;
} }

View file

@ -101,6 +101,13 @@ Future<client.Response> handleNewMessage(
utf8.decode(name), fromUserId.toInt(), true); utf8.decode(name), fromUserId.toInt(), true);
} }
break; break;
case MessageKind.opened:
await DbMessages.userOpenedMessageOtherUser(
fromUserId.toInt(),
message.messageId!,
message.timestamp,
);
break;
case MessageKind.rejectRequest: case MessageKind.rejectRequest:
DbContacts.deleteUser(fromUserId.toInt()); DbContacts.deleteUser(fromUserId.toInt());
break; break;

View file

@ -179,7 +179,7 @@ class ApiProvider {
final result = await _sendRequestV0(req); final result = await _sendRequestV0(req);
if (result.isError) { if (result.isError) {
log.shout(result); log.shout("Error auth", result);
return; return;
} }
@ -203,7 +203,7 @@ class ApiProvider {
final result2 = await _sendRequestV0(req2); final result2 = await _sendRequestV0(req2);
if (result2.isError) { if (result2.isError) {
log.shout(result2); log.shout("send request failed: ${result2.error}");
return; return;
} }

View file

@ -6,7 +6,6 @@ import 'package:twonly/src/model/messages_model.dart';
/// for every contact. /// for every contact.
class MessagesChangeProvider with ChangeNotifier, DiagnosticableTreeMixin { class MessagesChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
final Map<int, DbMessage> _lastMessage = <int, DbMessage>{}; final Map<int, DbMessage> _lastMessage = <int, DbMessage>{};
Map<int, DbMessage> get lastMessage => _lastMessage; Map<int, DbMessage> get lastMessage => _lastMessage;
void updateLastMessageFor(int targetUserId) async { void updateLastMessageFor(int targetUserId) async {

View file

@ -141,6 +141,10 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}); });
} }
}, },
onDoubleTap: () async {
cameraState.switchCameraSensor(
aspectRatio: CameraAspectRatios.ratio_16_9);
},
), ),
), ),
Positioned( Positioned(

View file

@ -1,16 +1,28 @@
import 'package:cv/cv.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/contacts_model.dart';
import 'package:twonly/src/model/json/message.dart'; import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/model/messages_model.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 { class ChatListEntry extends StatelessWidget {
const ChatListEntry(this.message, {super.key}); const ChatListEntry(this.message, this.user, this.lastMessageFromSameUser,
{super.key});
final DbMessage message; final DbMessage message;
final Contact user;
final bool lastMessageFromSameUser;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool right = message.messageOtherId == null; bool right = message.messageOtherId == null;
MessageSendState state = message.getSendState();
Widget child = Container(); Widget child = Container();
switch (message.messageKind) { switch (message.messageKind) {
@ -38,13 +50,52 @@ class ChatListEntry extends StatelessWidget {
), ),
); );
break; 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<int> 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: default:
return Container(); return Container();
} }
return Align( return Align(
alignment: right ? Alignment.centerRight : Alignment.centerLeft, 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<ChatItemDetailsView> { class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
List<DbMessage> _messages = []; List<DbMessage> _messages = [];
final TextEditingController newMessageController = TextEditingController();
@override @override
void initState() { void initState() {
@ -73,8 +125,23 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
await DbMessages.getAllMessagesForUser(widget.user.userId.toInt()); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final messages = context.watch<MessagesChangeProvider>().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(); // messages = messages.reversed.toList();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -87,7 +154,16 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
itemCount: _messages.length, // Number of items in the list itemCount: _messages.length, // Number of items in the list
reverse: true, reverse: true,
itemBuilder: (context, i) { 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<ChatItemDetailsView> {
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
// controller: _controller, controller: newMessageController,
onSubmitted: (_) {
_sendMessage();
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Type a message', hintText:
AppLocalizations.of(context)!.chatListDetailInput,
contentPadding: EdgeInsets.symmetric(horizontal: 10) contentPadding: EdgeInsets.symmetric(horizontal: 10)
// border: OutlineInputBorder(), // border: OutlineInputBorder(),
), ),
@ -110,31 +190,12 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.solidPaperPlane), icon: FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () { 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
// },
// ),
// ]),
// ),
], ],
), ),
); );

View file

@ -44,7 +44,11 @@ class _ChatListViewState extends State<ChatListView> {
Map<int, DbMessage> lastMessages = Map<int, DbMessage> lastMessages =
context.watch<MessagesChangeProvider>().lastMessage; context.watch<MessagesChangeProvider>().lastMessage;
List<Contact> allUsers = context.read<ContactChangeProvider>().allContacts; List<Contact> allUsers = context
.read<ContactChangeProvider>()
.allContacts
.where((c) => c.accepted)
.toList();
List<Contact> activeUsers = allUsers List<Contact> activeUsers = allUsers
.where((x) => lastMessages.containsKey(x.userId.toInt())) .where((x) => lastMessages.containsKey(x.userId.toInt()))
@ -80,11 +84,11 @@ class _ChatListViewState extends State<ChatListView> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: Icon((allUsers.isEmpty) icon: Icon((activeUsers.isEmpty)
? Icons.person_add ? Icons.person_add
: Icons.camera_alt), : Icons.camera_alt),
onPressed: () { onPressed: () {
(allUsers.isEmpty) (activeUsers.isEmpty)
? Navigator.push( ? Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -93,7 +97,7 @@ class _ChatListViewState extends State<ChatListView> {
) )
: globalUpdateOfHomeViewPageIndex(1); : globalUpdateOfHomeViewPageIndex(1);
}, },
label: Text((allUsers.isEmpty) label: Text((activeUsers.isEmpty)
? AppLocalizations.of(context)! ? AppLocalizations.of(context)!
.chatListViewSearchUserNameBtn .chatListViewSearchUserNameBtn
: AppLocalizations.of(context)! : AppLocalizations.of(context)!
@ -140,30 +144,11 @@ class _UserListItem extends State<UserListItem> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
MessageSendState state;
int lastMessageInSeconds = DateTime.now() int lastMessageInSeconds = DateTime.now()
.difference(widget.lastMessage.sendOrReceivedAt) .difference(widget.lastMessage.sendOrReceivedAt)
.inSeconds; .inSeconds;
if (!widget.lastMessage.messageAcknowledgeByServer) { MessageSendState state = widget.lastMessage.getSendState();
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;
}
}
}
return UserContextMenu( return UserContextMenu(
user: widget.user, user: widget.user,

View file

@ -27,9 +27,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
Future _initAsync() async { Future _initAsync() async {
List<int> token = widget.message.messageContent!.downloadToken!; List<int> token = widget.message.messageContent!.downloadToken!;
_imageByte = await getDownloadedMedia(token, widget.message.messageId); _imageByte = await getDownloadedMedia(token, widget.message.messageId);
print(_imageByte);
setState(() {}); setState(() {});
} }
@ -53,7 +51,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
_imageByte!, _imageByte!,
fit: BoxFit.contain, fit: BoxFit.contain,
) )
: CircularProgressIndicator()), : Container()),
), ),
), ),
_imageByte != null _imageByte != null

View file

@ -91,7 +91,9 @@ class _SearchUsernameView extends State<SearchUsernameView> {
}, },
controller: searchUserName, controller: searchUserName,
decoration: getInputDecoration( decoration: getInputDecoration(
AppLocalizations.of(context)!.searchUsernameInput))), AppLocalizations.of(context)!.searchUsernameInput),
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
OutlinedButton.icon( OutlinedButton.icon(
icon: Icon(Icons.qr_code), icon: Icon(Icons.qr_code),