From dd88747345795873d1389c82febabf3d4929279c Mon Sep 17 00:00:00 2001 From: iphydf Date: Sat, 22 Feb 2025 22:06:49 +0000 Subject: [PATCH] feat: Add chat bubbles and prettier text entry box. --- l10n.yaml | 2 + lib/api/toxcore/tox_events.dart | 66 +++---- lib/btox_app.dart | 2 +- lib/l10n/app_en.arb | 12 +- lib/models/crypto.dart | 72 ++++--- lib/models/messaging.dart | 35 ++-- lib/models/persistence.dart | 4 +- lib/packets/message_packet.dart | 64 ++++++ lib/packets/messagepack/packer.dart | 65 ++++--- lib/packets/messagepack/tags.dart | 31 +++ lib/packets/messagepack/unpacker.dart | 125 ++++++------ lib/packets/packet.dart | 15 ++ lib/pages/add_contact_page.dart | 2 +- lib/pages/chat_page.dart | 81 ++++---- lib/pages/contact_list_page.dart | 6 +- lib/pages/create_profile_page.dart | 2 +- lib/pages/settings_page.dart | 2 +- lib/pages/user_profile_page.dart | 2 +- lib/providers/tox.dart | 6 +- lib/widgets/attachment_button.dart | 27 +++ lib/widgets/attachment_selector.dart | 86 +++++++++ lib/widgets/bubble.dart | 98 ++++++++++ lib/widgets/chat_bubble.dart | 120 ++++++++++++ lib/widgets/contact_list_item.dart | 2 +- lib/widgets/friend_request_message_field.dart | 3 +- lib/widgets/main_menu.dart | 6 +- lib/widgets/message_input.dart | 132 +++++++++++++ lib/widgets/nickname_field.dart | 2 +- lib/widgets/status_message_field.dart | 2 +- lib/widgets/tox_id_field.dart | 3 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 6 + macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Release.entitlements | 2 + pubspec.lock | 34 +++- pubspec.yaml | 1 + test/btox_app_test.dart | 2 +- test/chat_page_test.dart | 182 ++++++++++++++++++ test/goldens/chat_empty.png | Bin 24222 -> 40479 bytes test/goldens/chat_hello.png | Bin 32348 -> 45194 bytes test/goldens/chat_page/empty.png | Bin 0 -> 4453 bytes test/goldens/chat_page/messages.png | Bin 0 -> 6199 bytes test/goldens/chat_page/messages_long.png | Bin 0 -> 6369 bytes test/goldens/chat_page/messages_time.png | Bin 0 -> 5267 bytes test/persistence_test.dart | 28 ++- 45 files changed, 1089 insertions(+), 245 deletions(-) create mode 100644 lib/packets/message_packet.dart create mode 100644 lib/packets/messagepack/tags.dart create mode 100644 lib/packets/packet.dart create mode 100644 lib/widgets/attachment_button.dart create mode 100644 lib/widgets/attachment_selector.dart create mode 100644 lib/widgets/bubble.dart create mode 100644 lib/widgets/chat_bubble.dart create mode 100644 lib/widgets/message_input.dart create mode 100644 test/chat_page_test.dart create mode 100644 test/goldens/chat_page/empty.png create mode 100644 test/goldens/chat_page/messages.png create mode 100644 test/goldens/chat_page/messages_long.png create mode 100644 test/goldens/chat_page/messages_time.png diff --git a/l10n.yaml b/l10n.yaml index 15338f2..2901215 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,3 +1,5 @@ arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart +output-dir: lib/l10n/generated +synthetic-package: false diff --git a/lib/api/toxcore/tox_events.dart b/lib/api/toxcore/tox_events.dart index c184c8c..cb36c0d 100644 --- a/lib/api/toxcore/tox_events.dart +++ b/lib/api/toxcore/tox_events.dart @@ -2,17 +2,17 @@ import 'dart:typed_data'; import 'package:btox/ffi/toxcore.dart'; +import 'package:btox/models/crypto.dart'; import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/packet.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'tox_events.freezed.dart'; part 'tox_events.g.dart'; -sealed class Event { +sealed class Event extends Packet { const Event(); - void pack(Packer packer); - factory Event.unpack(Unpacker unpacker, Tox_Event_Type type) { switch (type) { case Tox_Event_Type.TOX_EVENT_SELF_CONNECTION_STATUS: @@ -158,7 +158,7 @@ class ToxEventConferenceInvite extends Event with _$ToxEventConferenceInvite { factory ToxEventConferenceInvite.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventConferenceInvite( - cookie: Uint8List.fromList(unpacker.unpackBinary()), + cookie: unpacker.unpackBinary()!, type: Tox_Conference_Type.fromValue(unpacker.unpackInt()!), friendNumber: unpacker.unpackInt()!, ); @@ -195,7 +195,7 @@ class ToxEventConferenceMessage extends Event with _$ToxEventConferenceMessage { factory ToxEventConferenceMessage.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 4); return ToxEventConferenceMessage( - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, type: Tox_Message_Type.fromValue(unpacker.unpackInt()!), conferenceNumber: unpacker.unpackInt()!, peerNumber: unpacker.unpackInt()!, @@ -262,7 +262,7 @@ class ToxEventConferencePeerName extends Event factory ToxEventConferencePeerName.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventConferencePeerName( - name: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, conferenceNumber: unpacker.unpackInt()!, peerNumber: unpacker.unpackInt()!, ); @@ -298,7 +298,7 @@ class ToxEventConferenceTitle extends Event with _$ToxEventConferenceTitle { factory ToxEventConferenceTitle.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventConferenceTitle( - title: Uint8List.fromList(unpacker.unpackBinary()), + title: unpacker.unpackBinary()!, conferenceNumber: unpacker.unpackInt()!, peerNumber: unpacker.unpackInt()!, ); @@ -323,7 +323,7 @@ class ToxEventDhtNodesResponse extends Event with _$ToxEventDhtNodesResponse { converters: [Uint8ListConverter()], ) const factory ToxEventDhtNodesResponse({ - required Uint8List publicKey, + required PublicKey publicKey, required Uint8List ip, required int port, }) = _ToxEventDhtNodesResponse; @@ -334,8 +334,8 @@ class ToxEventDhtNodesResponse extends Event with _$ToxEventDhtNodesResponse { factory ToxEventDhtNodesResponse.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventDhtNodesResponse( - publicKey: Uint8List.fromList(unpacker.unpackBinary()), - ip: Uint8List.fromList(unpacker.unpackBinary()), + publicKey: PublicKey.unpack(unpacker), + ip: unpacker.unpackBinary()!, port: unpacker.unpackInt()!, ); } @@ -344,7 +344,7 @@ class ToxEventDhtNodesResponse extends Event with _$ToxEventDhtNodesResponse { void pack(Packer packer) { packer ..packListLength(3) - ..packBinary(publicKey) + ..pack(publicKey) ..packBinary(ip) ..packInt(port); } @@ -410,7 +410,7 @@ class ToxEventFileRecv extends Event with _$ToxEventFileRecv { factory ToxEventFileRecv.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 5); return ToxEventFileRecv( - filename: Uint8List.fromList(unpacker.unpackBinary()), + filename: unpacker.unpackBinary()!, fileNumber: unpacker.unpackInt()!, fileSize: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, @@ -451,7 +451,7 @@ class ToxEventFileRecvChunk extends Event with _$ToxEventFileRecvChunk { factory ToxEventFileRecvChunk.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 4); return ToxEventFileRecvChunk( - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, fileNumber: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, position: unpacker.unpackInt()!, @@ -558,7 +558,7 @@ class ToxEventFriendLosslessPacket extends Event factory ToxEventFriendLosslessPacket.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventFriendLosslessPacket( - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, dataLength: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, ); @@ -594,7 +594,7 @@ class ToxEventFriendLossyPacket extends Event with _$ToxEventFriendLossyPacket { factory ToxEventFriendLossyPacket.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 3); return ToxEventFriendLossyPacket( - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, dataLength: unpacker.unpackInt()!, friendNumber: unpacker.unpackInt()!, ); @@ -634,7 +634,7 @@ class ToxEventFriendMessage extends Event with _$ToxEventFriendMessage { friendNumber: unpacker.unpackInt()!, type: Tox_Message_Type.fromValue(unpacker.unpackInt()!), messageLength: unpacker.unpackInt()!, - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, ); } @@ -668,7 +668,7 @@ class ToxEventFriendName extends Event with _$ToxEventFriendName { factory ToxEventFriendName.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 2); return ToxEventFriendName( - name: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, friendNumber: unpacker.unpackInt()!, ); } @@ -724,7 +724,7 @@ class ToxEventFriendRequest extends Event with _$ToxEventFriendRequest { ) const factory ToxEventFriendRequest({ required Uint8List message, - required Uint8List publicKey, + required PublicKey publicKey, }) = _ToxEventFriendRequest; factory ToxEventFriendRequest.fromJson(Map json) => @@ -733,8 +733,8 @@ class ToxEventFriendRequest extends Event with _$ToxEventFriendRequest { factory ToxEventFriendRequest.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 2); return ToxEventFriendRequest( - message: Uint8List.fromList(unpacker.unpackBinary()), - publicKey: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, + publicKey: PublicKey.unpack(unpacker), ); } @@ -743,7 +743,7 @@ class ToxEventFriendRequest extends Event with _$ToxEventFriendRequest { packer ..packListLength(2) ..packBinary(message) - ..packBinary(publicKey); + ..pack(publicKey); } } @@ -799,7 +799,7 @@ class ToxEventFriendStatusMessage extends Event factory ToxEventFriendStatusMessage.unpack(Unpacker unpacker) { ensure(unpacker.unpackListLength(), 2); return ToxEventFriendStatusMessage( - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, friendNumber: unpacker.unpackInt()!, ); } @@ -867,7 +867,7 @@ class ToxEventGroupCustomPacket extends Event with _$ToxEventGroupCustomPacket { return ToxEventGroupCustomPacket( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, ); } @@ -905,7 +905,7 @@ class ToxEventGroupCustomPrivatePacket extends Event return ToxEventGroupCustomPrivatePacket( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - data: Uint8List.fromList(unpacker.unpackBinary()), + data: unpacker.unpackBinary()!, ); } @@ -940,8 +940,8 @@ class ToxEventGroupInvite extends Event with _$ToxEventGroupInvite { ensure(unpacker.unpackListLength(), 3); return ToxEventGroupInvite( friendNumber: unpacker.unpackInt()!, - inviteData: Uint8List.fromList(unpacker.unpackBinary()), - groupName: Uint8List.fromList(unpacker.unpackBinary()), + inviteData: unpacker.unpackBinary()!, + groupName: unpacker.unpackBinary()!, ); } @@ -1012,7 +1012,7 @@ class ToxEventGroupMessage extends Event with _$ToxEventGroupMessage { groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, messageType: Tox_Message_Type.fromValue(unpacker.unpackInt()!), - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, messageId: unpacker.unpackInt()!, ); } @@ -1087,7 +1087,7 @@ class ToxEventGroupPassword extends Event with _$ToxEventGroupPassword { ensure(unpacker.unpackListLength(), 2); return ToxEventGroupPassword( groupNumber: unpacker.unpackInt()!, - password: Uint8List.fromList(unpacker.unpackBinary()), + password: unpacker.unpackBinary()!, ); } @@ -1125,8 +1125,8 @@ class ToxEventGroupPeerExit extends Event with _$ToxEventGroupPeerExit { groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, exitType: Tox_Group_Exit_Type.fromValue(unpacker.unpackInt()!), - name: Uint8List.fromList(unpacker.unpackBinary()), - partMessage: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, + partMessage: unpacker.unpackBinary()!, ); } @@ -1228,7 +1228,7 @@ class ToxEventGroupPeerName extends Event with _$ToxEventGroupPeerName { return ToxEventGroupPeerName( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - name: Uint8List.fromList(unpacker.unpackBinary()), + name: unpacker.unpackBinary()!, ); } @@ -1335,7 +1335,7 @@ class ToxEventGroupPrivateMessage extends Event groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, messageType: Tox_Message_Type.fromValue(unpacker.unpackInt()!), - message: Uint8List.fromList(unpacker.unpackBinary()), + message: unpacker.unpackBinary()!, messageId: unpacker.unpackInt()!, ); } @@ -1400,7 +1400,7 @@ class ToxEventGroupTopic extends Event with _$ToxEventGroupTopic { return ToxEventGroupTopic( groupNumber: unpacker.unpackInt()!, peerId: unpacker.unpackInt()!, - topic: Uint8List.fromList(unpacker.unpackBinary()), + topic: unpacker.unpackBinary()!, ); } diff --git a/lib/btox_app.dart b/lib/btox_app.dart index 51ab61a..1b066af 100644 --- a/lib/btox_app.dart +++ b/lib/btox_app.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/pages/contact_list_page.dart'; import 'package:btox/pages/create_profile_page.dart'; import 'package:btox/pages/select_profile_page.dart'; @@ -7,7 +8,6 @@ import 'package:btox/providers/database.dart'; import 'package:btox/providers/sodium.dart'; import 'package:btox/providers/tox.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sodium/sodium.dart'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 50b5393..6f03005 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16,10 +16,20 @@ "@message": { "description": "The text for the message field" }, - "messageInput": "message", + "messageInput": "Tox message", "@messageInput": { "description": "The placeholder text for the message input field" }, + "re": "Re: {message}", + "@re": { + "description": "The prefix for a reply message", + "placeholders": { + "message": { + "type": "String", + "example": "Hello" + } + } + }, "toxId": "Tox ID", "@toxId": { "description": "The text for the Tox ID field" diff --git a/lib/models/crypto.dart b/lib/models/crypto.dart index e0ade57..b6bc101 100644 --- a/lib/models/crypto.dart +++ b/lib/models/crypto.dart @@ -1,4 +1,6 @@ import 'package:btox/models/bytes.dart'; +import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/packet.dart'; import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:drift/drift.dart'; @@ -18,7 +20,7 @@ final class NospamConverter extends TypeConverter int toSql(ToxAddressNospam value) => toJson(value); } -final class PublicKey extends _CryptoBytes { +final class PublicKey extends _CryptoBytes with Packet { static const kLength = 32; PublicKey(super.bytes); @@ -27,8 +29,17 @@ final class PublicKey extends _CryptoBytes { return PublicKey(Uint8List.fromList(hex.decode(value))); } + factory PublicKey.unpack(Unpacker unpacker) { + return PublicKey(unpacker.unpackBinary()!); + } + @override int get length => PublicKey.kLength; + + @override + void pack(Packer packer) { + packer.packBinary(bytes); + } } final class PublicKeyConverter extends TypeConverter @@ -50,12 +61,12 @@ final class SecretKey extends _CryptoBytes { SecretKey(super.bytes); - factory SecretKey.fromSodium(SecureKey value) { - return SecretKey(Uint8List.fromList(value.extractBytes())); + factory SecretKey.fromJson(String value) { + return SecretKey(Uint8List.fromList(hex.decode(value))); } - factory SecretKey.fromString(String value) { - return SecretKey(Uint8List.fromList(hex.decode(value))); + factory SecretKey.fromSodium(SecureKey value) { + return SecretKey(Uint8List.fromList(value.extractBytes())); } @override @@ -67,7 +78,7 @@ final class SecretKeyConverter extends TypeConverter const SecretKeyConverter(); @override - SecretKey fromJson(String json) => SecretKey.fromString(json); + SecretKey fromJson(String json) => SecretKey.fromJson(json); @override SecretKey fromSql(String fromDb) => fromJson(fromDb); @override @@ -76,7 +87,7 @@ final class SecretKeyConverter extends TypeConverter String toSql(SecretKey value) => toJson(value); } -final class Sha256 extends _CryptoBytes { +final class Sha256 extends _CryptoBytes with Packet { static const kLength = 32; Sha256(super.bytes); @@ -91,6 +102,11 @@ final class Sha256 extends _CryptoBytes { @override int get length => Sha256.kLength; + + @override + void pack(Packer packer) { + packer.packBinary(bytes); + } } final class Sha256Converter extends TypeConverter @@ -148,27 +164,6 @@ final class ToxAddressNospam extends _CryptoNumber { } } -sealed class _CryptoNumber { - final int value; - - const _CryptoNumber(this.value); - - @override - int get hashCode => value; - - @override - bool operator ==(Object other) { - return other is _CryptoNumber && - other.runtimeType == runtimeType && - other.value == value; - } - - @override - String toString() { - return '$runtimeType($value)'; - } -} - sealed class _CryptoBytes { final Uint8List bytes; @@ -199,3 +194,24 @@ sealed class _CryptoBytes { return '$runtimeType(${toJson()})'; } } + +sealed class _CryptoNumber { + final int value; + + const _CryptoNumber(this.value); + + @override + int get hashCode => value; + + @override + bool operator ==(Object other) { + return other is _CryptoNumber && + other.runtimeType == runtimeType && + other.value == value; + } + + @override + String toString() { + return '$runtimeType($value)'; + } +} diff --git a/lib/models/messaging.dart b/lib/models/messaging.dart index f1f3b75..b6e4976 100644 --- a/lib/models/messaging.dart +++ b/lib/models/messaging.dart @@ -1,45 +1,40 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:btox/db/database.dart'; import 'package:btox/models/crypto.dart'; import 'package:btox/models/id.dart'; import 'package:btox/models/persistence.dart'; -import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/message_packet.dart'; import 'package:crypto/crypto.dart'; -// [parent, merge, timestamp, origin, content] -Uint8List encodeMessage(Message? parent, Message? merge, DateTime timestamp, - PublicKey origin, String content) { - final Packer packer = Packer(); - - packer - ..packListLength(5) - ..packBinary(parent?.sha.bytes) - ..packBinary(merge?.sha.bytes) - ..packInt(timestamp.millisecondsSinceEpoch) - ..packBinary(origin.bytes) - ..packBinary(utf8.encode(content)); - - return packer.takeBytes(); +// [parent, merged, timestamp, author, content] +Uint8List encodeMessage(Message? parent, Message? merged, DateTime timestamp, + PublicKey author, String content) { + return MessagePacket( + parent: parent?.sha, + merged: merged?.sha, + timestamp: timestamp, + author: author, + content: content, + ).encode(); } MessagesCompanion newMessage({ required Id contactId, required Message? parent, - Message? merged, - required PublicKey origin, + required Message? merged, + required PublicKey author, required DateTime timestamp, required String content, }) { return MessagesCompanion.insert( contactId: contactId, parent: Value.absentIfNull(parent?.id), - origin: origin, + author: author, timestamp: timestamp, content: content, sha: Sha256.fromDigest( - sha256.convert(encodeMessage(parent, merged, timestamp, origin, content)), + sha256.convert(encodeMessage(parent, merged, timestamp, author, content)), ), ); } diff --git a/lib/models/persistence.dart b/lib/models/persistence.dart index 25eb4a1..c381bbd 100644 --- a/lib/models/persistence.dart +++ b/lib/models/persistence.dart @@ -21,7 +21,7 @@ abstract class Messages extends Table { IntColumn get contactId => integer().references(Contacts, #id).map(const IdConverter())(); - TextColumn get origin => text().map(const PublicKeyConverter())(); + TextColumn get author => text().map(const PublicKeyConverter())(); // The SHA-256 hash of the message content, timestamp, and parent/merge hashes. TextColumn get sha => text() @@ -31,7 +31,7 @@ abstract class Messages extends Table { .references(Messages, #id) .map(const IdConverter()) .nullable()(); - IntColumn get merge => integer() + IntColumn get merged => integer() .references(Messages, #id) .map(const IdConverter()) .nullable()(); diff --git a/lib/packets/message_packet.dart b/lib/packets/message_packet.dart new file mode 100644 index 0000000..487d042 --- /dev/null +++ b/lib/packets/message_packet.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:btox/models/crypto.dart'; +import 'package:btox/packets/messagepack.dart'; +import 'package:btox/packets/packet.dart'; +import 'package:crypto/crypto.dart'; + +final class MessagePacket extends Packet { + final Sha256? parent; + final Sha256? merged; + final DateTime timestamp; + final PublicKey author; + final String content; + + const MessagePacket({ + required this.parent, + required this.merged, + required this.timestamp, + required this.author, + required this.content, + }); + + factory MessagePacket.unpack(Unpacker unpacker) { + final int length = unpacker.unpackListLength(); + if (length != 5) { + throw Exception('Invalid message packet'); + } + + final Sha256? parent = + unpacker.unpackBinary()?.let((sha) => Sha256.fromDigest(Digest(sha))); + final Sha256? merged = + unpacker.unpackBinary()?.let((sha) => Sha256.fromDigest(Digest(sha))); + final DateTime timestamp = + DateTime.fromMillisecondsSinceEpoch(unpacker.unpackInt()!); + final PublicKey author = PublicKey.unpack(unpacker); + final String content = utf8.decode(unpacker.unpackBinary()!); + + return MessagePacket( + parent: parent, + merged: merged, + timestamp: timestamp, + author: author, + content: content, + ); + } + + @override + void pack(Packer packer) { + packer + ..packListLength(5) + ..packBinary(parent?.bytes) + ..packBinary(merged?.bytes) + ..packInt(timestamp.millisecondsSinceEpoch) + ..packBinary(author.bytes) + ..packBinary(utf8.encode(content)); + } +} + +extension on Uint8List { + T let(T Function(Uint8List) f) { + return f(this); + } +} diff --git a/lib/packets/messagepack/packer.dart b/lib/packets/messagepack/packer.dart index 58bc938..21abd9f 100644 --- a/lib/packets/messagepack/packer.dart +++ b/lib/packets/messagepack/packer.dart @@ -4,6 +4,9 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:btox/packets/messagepack/tags.dart'; +import 'package:btox/packets/packet.dart'; + // dart2js doesn't support 64 bit ints, so we pack using 2x 32 bit ints. void _setUint64(ByteData d, int offset, int v) { d.setUint32(offset, v >> 32); @@ -25,7 +28,7 @@ void _setInt64(ByteData d, int offset, int v) { /// Streaming packing requires buffer to collect your data. /// Try to figure out the best initial size of this buffer, that minimal enough to fit your most common data packing scenario. /// Try to find balance. Provide this value in constructor [Packer()] -class Packer { +final class Packer { /// Provide the [_bufSize] size, that minimal enough to fit your most used data packets. /// Try to find balance, small buffer is good, and if most of your data will fit to it, performance will be good. /// If buffer not enough it will be increased automatically. @@ -82,7 +85,7 @@ class Packer { /// Other packXXX implicitly handle null values. void packNull() { if (_buf.length - _offset < 1) _nextBuf(); - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } /// Pack [bool] or `null`. @@ -90,9 +93,9 @@ class Packer { void packBool(bool? v) { if (_buf.length - _offset < 1) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else { - _d.setUint8(_offset++, v ? 0xc3 : 0xc2); + _d.setUint8(_offset++, v ? kTagTrue : kTagFalse); } } @@ -101,41 +104,41 @@ class Packer { // max 8 byte int + 1 control byte if (_buf.length - _offset < 9) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else if (v >= 0) { if (v <= 127) { _d.setUint8(_offset++, v); } else if (v <= 0xFF) { - _d.setUint8(_offset++, 0xcc); + _d.setUint8(_offset++, kTagUint8); _d.setUint8(_offset++, v); } else if (v <= 0xFFFF) { - _d.setUint8(_offset++, 0xcd); + _d.setUint8(_offset++, kTagUint16); _d.setUint16(_offset, v); _offset += 2; } else if (v <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xce); + _d.setUint8(_offset++, kTagUint32); _d.setUint32(_offset, v); _offset += 4; } else { - _d.setUint8(_offset++, 0xcf); + _d.setUint8(_offset++, kTagUint64); _setUint64(_d, _offset, v); _offset += 8; } } else if (v >= -32) { _d.setInt8(_offset++, v); } else if (v >= -128) { - _d.setUint8(_offset++, 0xd0); + _d.setUint8(_offset++, kTagInt8); _d.setInt8(_offset++, v); } else if (v >= -32768) { - _d.setUint8(_offset++, 0xd1); + _d.setUint8(_offset++, kTagInt16); _d.setInt16(_offset, v); _offset += 2; } else if (v >= -2147483648) { - _d.setUint8(_offset++, 0xd2); + _d.setUint8(_offset++, kTagInt32); _d.setInt32(_offset, v); _offset += 4; } else { - _d.setUint8(_offset++, 0xd3); + _d.setUint8(_offset++, kTagInt64); _setInt64(_d, _offset, v); _offset += 8; } @@ -146,10 +149,10 @@ class Packer { // 8 byte double + 1 control byte if (_buf.length - _offset < 9) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); return; } - _d.setUint8(_offset++, 0xcb); + _d.setUint8(_offset++, kTagFloat64); _d.setFloat64(_offset, v); _offset += 8; } @@ -169,7 +172,7 @@ class Packer { // max 4 byte str header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (v == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); return; } final encoded = _strCodec.encode(v); @@ -177,14 +180,14 @@ class Packer { if (length <= 31) { _d.setUint8(_offset++, 0xA0 | length); } else if (length <= 0xFF) { - _d.setUint8(_offset++, 0xd9); + _d.setUint8(_offset++, kTagStr8); _d.setUint8(_offset++, length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xda); + _d.setUint8(_offset++, kTagStr16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xdb); + _d.setUint8(_offset++, kTagStr32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -210,19 +213,19 @@ class Packer { // max 4 byte binary header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (buffer == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); return; } final length = buffer.length; if (length <= 0xFF) { - _d.setUint8(_offset++, 0xc4); + _d.setUint8(_offset++, kTagBin8); _d.setUint8(_offset++, length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xc5); + _d.setUint8(_offset++, kTagBin16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xc6); + _d.setUint8(_offset++, kTagBin32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -236,15 +239,15 @@ class Packer { // max 4 length header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (length == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else if (length <= 0xF) { _d.setUint8(_offset++, 0x90 | length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xdc); + _d.setUint8(_offset++, kTagArray16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xdd); + _d.setUint8(_offset++, kTagArray32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -257,15 +260,15 @@ class Packer { // max 4 byte header + 1 control byte if (_buf.length - _offset < 5) _nextBuf(); if (length == null) { - _d.setUint8(_offset++, 0xc0); + _d.setUint8(_offset++, kTagNil); } else if (length <= 0xF) { _d.setUint8(_offset++, 0x80 | length); } else if (length <= 0xFFFF) { - _d.setUint8(_offset++, 0xde); + _d.setUint8(_offset++, kTagMap16); _d.setUint16(_offset, length); _offset += 2; } else if (length <= 0xFFFFFFFF) { - _d.setUint8(_offset++, 0xdf); + _d.setUint8(_offset++, kTagMap32); _d.setUint32(_offset, length); _offset += 4; } else { @@ -273,6 +276,10 @@ class Packer { } } + void pack(Packet packet) { + packet.pack(this); + } + /// Get bytes representation of this packer. /// Note: after this call do not reuse packer - create new. Uint8List takeBytes() { diff --git a/lib/packets/messagepack/tags.dart b/lib/packets/messagepack/tags.dart new file mode 100644 index 0000000..13ba65c --- /dev/null +++ b/lib/packets/messagepack/tags.dart @@ -0,0 +1,31 @@ +const kTagNil = 0xc0; // 11000000 +const kTagFalse = 0xc2; // 11000010 +const kTagTrue = 0xc3; // 11000011 +const kTagBin8 = 0xc4; // 11000100 +const kTagBin16 = 0xc5; // 11000101 +const kTagBin32 = 0xc6; // 11000110 +const kTagExt8 = 0xc7; // 11000111 +const kTagExt16 = 0xc8; // 11001000 +const kTagExt32 = 0xc9; // 11001001 +const kTagFloat32 = 0xca; // 11001010 +const kTagFloat64 = 0xcb; // 11001011 +const kTagUint8 = 0xcc; // 11001100 +const kTagUint16 = 0xcd; // 11001101 +const kTagUint32 = 0xce; // 11001110 +const kTagUint64 = 0xcf; // 11001111 +const kTagInt8 = 0xd0; // 11010000 +const kTagInt16 = 0xd1; // 11010001 +const kTagInt32 = 0xd2; // 11010010 +const kTagInt64 = 0xd3; // 11010011 +const kTagFixext1 = 0xd4; // 11010100 +const kTagFixext2 = 0xd5; // 11010101 +const kTagFixext4 = 0xd6; // 11010110 +const kTagFixext8 = 0xd7; // 11010111 +const kTagFixext16 = 0xd8; // 11011000 +const kTagStr8 = 0xd9; // 11011001 +const kTagStr16 = 0xda; // 11011010 +const kTagStr32 = 0xdb; // 11011011 +const kTagArray16 = 0xdc; // 11011100 +const kTagArray32 = 0xdd; // 11011101 +const kTagMap16 = 0xde; // 11011110 +const kTagMap32 = 0xdf; // 11011111 diff --git a/lib/packets/messagepack/unpacker.dart b/lib/packets/messagepack/unpacker.dart index e30eb9f..83aaf36 100644 --- a/lib/packets/messagepack/unpacker.dart +++ b/lib/packets/messagepack/unpacker.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:btox/packets/messagepack/tags.dart'; + // dart2js doesn't support 64 bit ints, so we unpack using 2x 32 bit ints. int _getUint64(ByteData d, int offset) => (d.getUint32(offset) << 32) | d.getUint32(offset + 4); @@ -17,9 +19,9 @@ int _getInt64(ByteData d, int offset) => /// Throws [FormatException] if value is not an requested type, /// but in that case throwing exception not corrupt internal state, /// so other unpackXXX methods can be called after that. -class Unpacker { +final class Unpacker { /// Manipulates with provided [Uint8List] to sequentially unpack values. - /// Use [Unpaker.fromList()] to unpack raw `List` bytes. + /// Use [Unpacker.fromList()] to unpack raw `List` bytes. Unpacker(this._list) : _d = ByteData.view(_list.buffer, _list.offsetInBytes); ///Convenient @@ -29,7 +31,14 @@ class Unpacker { final ByteData _d; int _offset = 0; - final _strCodec = const Utf8Codec(); + bool get hasNull => _d.getUint8(_offset) == kTagNil; + + void unpackNull() { + if (!hasNull) { + throw _formatException('null', _d.getUint8(_offset)); + } + _offset += 1; + } /// Unpack value if it exist. Otherwise returns `null`. /// @@ -37,13 +46,13 @@ class Unpacker { bool? unpackBool() { final b = _d.getUint8(_offset); bool? v; - if (b == 0xc2) { + if (b == kTagFalse) { v = false; _offset += 1; - } else if (b == 0xc3) { + } else if (b == kTagTrue) { v = true; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { v = null; _offset += 1; } else { @@ -62,31 +71,31 @@ class Unpacker { /// Int value in fixnum range [-32..127] encoded in header 1 byte v = _d.getInt8(_offset); _offset += 1; - } else if (b == 0xcc) { + } else if (b == kTagUint8) { v = _d.getUint8(++_offset); _offset += 1; - } else if (b == 0xcd) { + } else if (b == kTagUint16) { v = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xce) { + } else if (b == kTagUint32) { v = _d.getUint32(++_offset); _offset += 4; - } else if (b == 0xcf) { + } else if (b == kTagUint64) { v = _getUint64(_d, ++_offset); _offset += 8; - } else if (b == 0xd0) { + } else if (b == kTagInt8) { v = _d.getInt8(++_offset); _offset += 1; - } else if (b == 0xd1) { + } else if (b == kTagInt16) { v = _d.getInt16(++_offset); _offset += 2; - } else if (b == 0xd2) { + } else if (b == kTagInt32) { v = _d.getInt32(++_offset); _offset += 4; - } else if (b == 0xd3) { + } else if (b == kTagInt64) { v = _getInt64(_d, ++_offset); _offset += 8; - } else if (b == 0xc0) { + } else if (b == kTagNil) { v = null; _offset += 1; } else { @@ -101,13 +110,13 @@ class Unpacker { double? unpackDouble() { final b = _d.getUint8(_offset); double? v; - if (b == 0xca) { + if (b == kTagFloat32) { v = _d.getFloat32(++_offset); _offset += 4; - } else if (b == 0xcb) { + } else if (b == kTagFloat64) { v = _d.getFloat64(++_offset); _offset += 8; - } else if (b == 0xc0) { + } else if (b == kTagNil) { v = null; _offset += 1; } else { @@ -122,26 +131,26 @@ class Unpacker { /// Throws [FormatException] if value is not a String. String? unpackString() { final b = _d.getUint8(_offset); - if (b == 0xc0) { + if (b == kTagNil) { _offset += 1; return null; } int len; - /// fixstr 101XXXXX stores a byte array whose len is upto 31 bytes: + /// fixstr 101XXXXX stores a byte array whose len is up to 31 bytes: if (b & 0xE0 == 0xA0) { len = b & 0x1F; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { _offset += 1; return null; - } else if (b == 0xd9) { + } else if (b == kTagStr8) { len = _d.getUint8(++_offset); _offset += 1; - } else if (b == 0xda) { + } else if (b == kTagStr16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xdb) { + } else if (b == kTagStr32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -150,7 +159,7 @@ class Unpacker { final data = Uint8List.view(_list.buffer, _list.offsetInBytes + _offset, len); _offset += len; - return _strCodec.decode(data); + return utf8.decode(data); } /// Unpack [List.length] if packed value is an [List] or `null`. @@ -162,16 +171,16 @@ class Unpacker { final b = _d.getUint8(_offset); int len; if (b & 0xF0 == 0x90) { - /// fixarray 1001XXXX stores an array whose length is upto 15 elements: + /// fixarray 1001XXXX stores an array whose length is up to 15 elements: len = b & 0xF; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { len = 0; _offset += 1; - } else if (b == 0xdc) { + } else if (b == kTagArray16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xdd) { + } else if (b == kTagArray32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -189,16 +198,16 @@ class Unpacker { final b = _d.getUint8(_offset); int len; if (b & 0xF0 == 0x80) { - /// fixmap 1000XXXX stores a map whose length is upto 15 elements + /// fixmap 1000XXXX stores a map whose length is up to 15 elements len = b & 0xF; _offset += 1; - } else if (b == 0xc0) { + } else if (b == kTagNil) { len = 0; _offset += 1; - } else if (b == 0xde) { + } else if (b == kTagMap16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xdf) { + } else if (b == kTagMap32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -211,19 +220,19 @@ class Unpacker { /// /// Encoded in msgpack packet null unpacks to [List] with 0 length for convenience. /// Throws [FormatException] if value is not a binary. - List unpackBinary() { + Uint8List? unpackBinary() { final b = _d.getUint8(_offset); int len; - if (b == 0xc4) { - len = _d.getUint8(++_offset); + if (b == kTagNil) { _offset += 1; - } else if (b == 0xc0) { - len = 0; + return null; + } else if (b == kTagBin8) { + len = _d.getUint8(++_offset); _offset += 1; - } else if (b == 0xc5) { + } else if (b == kTagBin16) { len = _d.getUint16(++_offset); _offset += 2; - } else if (b == 0xc6) { + } else if (b == kTagBin32) { len = _d.getUint32(++_offset); _offset += 4; } else { @@ -232,37 +241,37 @@ class Unpacker { final data = Uint8List.view(_list.buffer, _list.offsetInBytes + _offset, len); _offset += len; - return data.toList(); + return data.asUnmodifiableView(); } Object? _unpack() { final b = _d.getUint8(_offset); if (b <= 0x7f || b >= 0xe0 || - b == 0xcc || - b == 0xcd || - b == 0xce || - b == 0xcf || - b == 0xd0 || - b == 0xd1 || - b == 0xd2 || - b == 0xd3) { + b == kTagUint8 || + b == kTagUint16 || + b == kTagUint32 || + b == kTagUint64 || + b == kTagInt8 || + b == kTagInt16 || + b == kTagInt32 || + b == kTagInt64) { return unpackInt(); - } else if (b == 0xc0 || b == 0xc2 || b == 0xc3) { + } else if (b == kTagNil || b == kTagFalse || b == kTagTrue) { return unpackBool(); //null included - } else if (b == 0xca || b == 0xcb) { + } else if (b == kTagFloat32 || b == kTagFloat64) { return unpackDouble(); } else if ((b & 0xE0) == 0xA0 || - b == 0xc0 || - b == 0xd9 || - b == 0xda || - b == 0xdb) { + b == kTagNil || + b == kTagStr8 || + b == kTagStr16 || + b == kTagStr32) { return unpackString(); - } else if (b == 0xc4 || b == 0xc5 || b == 0xc6) { + } else if (b == kTagBin8 || b == kTagBin16 || b == kTagBin32) { return unpackBinary(); - } else if ((b & 0xF0) == 0x90 || b == 0xdc || b == 0xdd) { + } else if ((b & 0xF0) == 0x90 || b == kTagArray16 || b == kTagArray32) { return unpackList(); - } else if ((b & 0xF0) == 0x80 || b == 0xde || b == 0xdf) { + } else if ((b & 0xF0) == 0x80 || b == kTagMap16 || b == kTagMap32) { return unpackMap(); } else { throw _formatException('Unknown', b); diff --git a/lib/packets/packet.dart b/lib/packets/packet.dart new file mode 100644 index 0000000..37f9de8 --- /dev/null +++ b/lib/packets/packet.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +import 'package:btox/packets/messagepack.dart'; + +abstract mixin class Packet { + const Packet(); + + Uint8List encode() { + final Packer packer = Packer(); + pack(packer); + return packer.takeBytes(); + } + + void pack(Packer packer); +} diff --git a/lib/pages/add_contact_page.dart b/lib/pages/add_contact_page.dart index ca7a934..24d976d 100644 --- a/lib/pages/add_contact_page.dart +++ b/lib/pages/add_contact_page.dart @@ -1,8 +1,8 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/widgets/friend_request_message_field.dart'; import 'package:btox/widgets/tox_id_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; final class AddContactPage extends HookWidget { diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 8e7b40c..b93ecb8 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,24 +1,29 @@ import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; +import 'package:btox/widgets/attachment_selector.dart'; +import 'package:btox/widgets/chat_bubble.dart'; +import 'package:btox/widgets/message_input.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; final class ChatPage extends HookWidget { + final Profile profile; final Stream contact; final Stream> messages; - final void Function(Message? parent, String message) onSendMessage; + final void Function(Message? parent, String message)? onSendMessage; const ChatPage({ super.key, + required this.profile, required this.contact, required this.messages, - required this.onSendMessage, + this.onSendMessage, }); @override Widget build(BuildContext context) { - final messageInputController = useTextEditingController(); - final messageInputFocus = useFocusNode(); + final replyingTo = useState(null); + final selectingAttachment = useState(false); return StreamBuilder( stream: contact, @@ -30,7 +35,7 @@ final class ChatPage extends HookWidget { body: StreamBuilder>( stream: messages, builder: ((context, snapshot) { - final messages = snapshot.data ?? []; + final messages = snapshot.data ?? const []; return Column( children: [ Expanded( @@ -40,40 +45,42 @@ final class ChatPage extends HookWidget { itemBuilder: (context, index) { final reversedIndex = messages.length - index - 1; final message = messages[reversedIndex]; - return ListTile( - title: Text(message.content), - subtitle: Text(message.timestamp.toLocal().toString()), + return ChatBubble( + message: message, + isSender: message.author == profile.publicKey, + onReply: () { + replyingTo.value = message; + }, ); }, ), ), Padding( padding: const EdgeInsets.all(16), - child: TextField( - key: const Key('messageField'), - decoration: InputDecoration( - border: const UnderlineInputBorder(), - labelText: AppLocalizations.of(context)!.messageInput, - suffixIcon: IconButton( - onPressed: () => _onSendMessage( - messages.lastOrNull, - messageInputController, - messageInputFocus, - ), - icon: const Icon(Icons.send), - ), - ), - onEditingComplete: () => _onSendMessage( - messages.lastOrNull, - messageInputController, - messageInputFocus, - ), - controller: messageInputController, - focusNode: messageInputFocus, - textInputAction: TextInputAction.send, - autofocus: true, + child: MessageInput( + hintText: AppLocalizations.of(context)!.messageInput, + replyingTo: replyingTo.value?.content ?? '', + onAdd: () { + selectingAttachment.value = !selectingAttachment.value; + }, + onSend: (message) { + onSendMessage?.call( + messages.lastOrNull, + message, + ); + replyingTo.value = null; + }, + onTapCloseReply: () { + replyingTo.value = null; + }, ), ), + if (selectingAttachment.value) + AttachmentSelector( + onAdd: () { + selectingAttachment.value = false; + }, + ), ], ); }), @@ -81,14 +88,4 @@ final class ChatPage extends HookWidget { ), ); } - - void _onSendMessage( - Message? parent, - TextEditingController messageInputController, - FocusNode messageInputFocus, - ) { - onSendMessage(parent, messageInputController.text); - messageInputController.clear(); - messageInputFocus.requestFocus(); - } } diff --git a/lib/pages/contact_list_page.dart b/lib/pages/contact_list_page.dart index 2520566..2980963 100644 --- a/lib/pages/contact_list_page.dart +++ b/lib/pages/contact_list_page.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:btox/models/crypto.dart'; import 'package:btox/models/messaging.dart'; @@ -10,7 +11,6 @@ import 'package:btox/widgets/contact_list_item.dart'; import 'package:btox/widgets/main_menu.dart'; import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; const _logger = Logger(['ContactListPage']); @@ -78,13 +78,15 @@ final class ContactListPage extends ConsumerWidget { context, MaterialPageRoute( builder: (context) => ChatPage( + profile: profile, contact: database.watchContact(contact.id), messages: database.watchMessagesFor(contact.id), onSendMessage: (Message? parent, String message) { database.addMessage(newMessage( contactId: contact.id, parent: parent, - origin: profile.publicKey, + merged: null, + author: profile.publicKey, timestamp: clock.now().toUtc(), content: message, )); diff --git a/lib/pages/create_profile_page.dart b/lib/pages/create_profile_page.dart index 416bb5a..ae76013 100644 --- a/lib/pages/create_profile_page.dart +++ b/lib/pages/create_profile_page.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:btox/models/crypto.dart'; import 'package:btox/models/id.dart'; @@ -8,7 +9,6 @@ import 'package:btox/models/profile_settings.dart'; import 'package:btox/widgets/nickname_field.dart'; import 'package:btox/widgets/status_message_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:sodium/sodium.dart'; diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 20dc91b..dd700e3 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,7 +1,7 @@ import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; const _logger = Logger(['SettingsPage']); diff --git a/lib/pages/user_profile_page.dart b/lib/pages/user_profile_page.dart index 337d2f6..8579023 100644 --- a/lib/pages/user_profile_page.dart +++ b/lib/pages/user_profile_page.dart @@ -1,10 +1,10 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/models/profile_settings.dart'; import 'package:btox/widgets/nickname_field.dart'; import 'package:btox/widgets/status_message_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; final class UserProfilePage extends HookWidget { diff --git a/lib/providers/tox.dart b/lib/providers/tox.dart index 7745e99..8d39c64 100644 --- a/lib/providers/tox.dart +++ b/lib/providers/tox.dart @@ -44,9 +44,11 @@ Stream toxEvents( .nodes .where((node) => node.tcpPorts.isNotEmpty) .toList(growable: false); - _logger.d('Got ${nodes.length} bootstrap nodes; using 8...'); + final selectedNodes = nodes.take(8); + _logger.d('Got ${nodes.length} bootstrap nodes; ' + 'using ${selectedNodes.length}...'); try { - for (final node in nodes.take(8)) { + for (final node in selectedNodes) { tox.bootstrap(node.ipv4, node.port, node.publicKey); tox.addTcpRelay(node.ipv4, node.tcpPorts.first, node.publicKey); } diff --git a/lib/widgets/attachment_button.dart b/lib/widgets/attachment_button.dart new file mode 100644 index 0000000..a8fdd16 --- /dev/null +++ b/lib/widgets/attachment_button.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +final class AttachmentButton extends StatelessWidget { + final IconData icon; + final String text; + final void Function() onPressed; + + const AttachmentButton({ + super.key, + required this.icon, + required this.text, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + IconButton( + icon: Icon(icon, size: 56), + onPressed: onPressed, + ), + Text(text), + ], + ); + } +} diff --git a/lib/widgets/attachment_selector.dart b/lib/widgets/attachment_selector.dart new file mode 100644 index 0000000..0056dfd --- /dev/null +++ b/lib/widgets/attachment_selector.dart @@ -0,0 +1,86 @@ +import 'package:btox/logger.dart'; +import 'package:btox/widgets/attachment_button.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +const _logger = Logger(['AttachmentSelector']); + +final class AttachmentSelector extends StatelessWidget { + final void Function() onAdd; + + const AttachmentSelector({ + super.key, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Table( + children: [ + TableRow( + children: [ + AttachmentButton( + icon: Icons.camera_alt, + text: 'Camera', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Camera not implemented'), + ), + ); + }, + ), + AttachmentButton( + icon: Icons.photo, + text: 'Gallery', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gallery not implemented'), + ), + ); + }, + ), + AttachmentButton( + icon: Icons.mic, + text: 'Audio', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Audio not implemented'), + ), + ); + }, + ), + AttachmentButton( + icon: Icons.file_copy, + text: 'File', + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + ); + if (result != null) { + _logger.d('Files selected: ${result.files}'); + } + }, + ), + AttachmentButton( + icon: Icons.location_on, + text: 'Location', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Location not implemented'), + ), + ); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/bubble.dart b/lib/widgets/bubble.dart new file mode 100644 index 0000000..3792cc2 --- /dev/null +++ b/lib/widgets/bubble.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +const double kStateIconBottom = 4; +const double kStateIconRight = 6; +const double kStateIconSize = 18; + +final class Bubble extends StatelessWidget { + final String text; + final Color color; + final double bubbleRadius; + final BubbleDirection direction; + final BubbleState state; + final TextStyle? textStyle; + final EdgeInsets padding; + final double extraWidth; + + const Bubble({ + super.key, + required this.text, + required this.color, + this.bubbleRadius = 16, + this.direction = BubbleDirection.sent, + this.state = BubbleState.none, + this.textStyle, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + this.extraWidth = 48, + }); + + @override + Widget build(BuildContext context) { + final Positioned? stateIcon = switch (state) { + BubbleState.none => null, + BubbleState.sent => const Positioned( + bottom: kStateIconBottom, + right: kStateIconRight, + child: Icon( + Icons.done, + size: kStateIconSize, + color: Color(0xFF97AD8E), + ), + ), + BubbleState.delivered => const Positioned( + bottom: kStateIconBottom, + right: kStateIconRight, + child: Icon( + Icons.done_all, + size: kStateIconSize, + color: Color(0xFF97AD8E), + ), + ), + BubbleState.seen => const Positioned( + bottom: kStateIconBottom, + right: kStateIconRight, + child: Icon( + Icons.done_all, + size: kStateIconSize, + color: Color(0xFF92DEDA), + ), + ), + }; + + return Row( + mainAxisAlignment: switch (direction) { + BubbleDirection.sent => MainAxisAlignment.end, + BubbleDirection.received => MainAxisAlignment.start, + }, + children: [ + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - extraWidth, + ), + padding: padding, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.all(Radius.circular(bubbleRadius)), + ), + child: Stack( + children: [ + Padding( + padding: stateIcon != null + ? EdgeInsets.fromLTRB(12, 6, 28, 6) + : EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Text(text, style: textStyle), + ), + if (stateIcon != null) stateIcon, + ], + ), + ), + ), + ], + ); + } +} + +enum BubbleDirection { sent, received } + +enum BubbleState { none, sent, delivered, seen } diff --git a/lib/widgets/chat_bubble.dart b/lib/widgets/chat_bubble.dart new file mode 100644 index 0000000..a6c8dc6 --- /dev/null +++ b/lib/widgets/chat_bubble.dart @@ -0,0 +1,120 @@ +import 'dart:math'; + +import 'package:btox/db/database.dart'; +import 'package:btox/widgets/bubble.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// When the bubble is dragged past this fraction of the max bubble drag, the +/// reply icon will grow, indicating that the message will be replied to. +const _kBubbleDragActivation = 0.8; + +/// The maximum drag distance for the bubble (in either direction). +const _kMaxBubbleDrag = 48.0; + +Color _bubbleColor(bool isSender, ThemeData theme) { + return isSender ? Colors.blue : theme.splashColor; +} + +final class ChatBubble extends HookWidget { + final Message message; + final bool isSender; + final void Function()? onReply; + + const ChatBubble({ + super.key, + required this.message, + required this.isSender, + this.onReply, + }); + + @override + Widget build(BuildContext context) { + final bubbleDrag = useState(0.0); + final showTime = useState(false); + + final dragValue = + isSender ? max(0.0, -bubbleDrag.value) : max(0.0, bubbleDrag.value); + final replyIconExtraSize = + dragValue > _kMaxBubbleDrag * _kBubbleDragActivation ? 8.0 : 0.0; + + return Column( + children: [ + Stack( + children: [ + _align(Padding( + padding: EdgeInsets.all(8.0 - replyIconExtraSize / 2), + // Transparent to opaque depending on drag state. + child: Opacity( + opacity: dragValue / _kMaxBubbleDrag, + child: Icon( + Icons.reply, + size: 24 + replyIconExtraSize, + ), + ), + )), + GestureDetector( + onHorizontalDragUpdate: (details) { + final delta = details.primaryDelta; + if (delta == null) { + return; + } + bubbleDrag.value = clampDouble( + bubbleDrag.value + delta, + -_kMaxBubbleDrag, + _kMaxBubbleDrag, + ); + }, + onHorizontalDragEnd: (details) { + if (dragValue / _kMaxBubbleDrag > _kBubbleDragActivation) { + onReply?.call(); + } + bubbleDrag.value = 0; + }, + onTap: () => showTime.value = !showTime.value, + child: Padding( + padding: isSender + ? EdgeInsets.only(right: dragValue) + : EdgeInsets.only(left: dragValue), + child: Bubble( + text: message.content, + extraWidth: _kMaxBubbleDrag, + color: _bubbleColor(isSender, Theme.of(context)), + direction: isSender + ? BubbleDirection.sent + : BubbleDirection.received, + state: isSender ? BubbleState.seen : BubbleState.none, + textStyle: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ], + ), + if (showTime.value) + _align( + Text( + message.timestamp + .toLocal() + .toIso8601String() + .split('T') + .last + .split('.') + .first, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } + + Widget _align(Widget child) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: isSender ? Alignment.topRight : Alignment.topLeft, + child: child, + ), + ); + } +} diff --git a/lib/widgets/contact_list_item.dart b/lib/widgets/contact_list_item.dart index 46e5030..f8e6e46 100644 --- a/lib/widgets/contact_list_item.dart +++ b/lib/widgets/contact_list_item.dart @@ -1,7 +1,7 @@ import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/widgets/circle_identicon.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; final class ContactListItem extends StatelessWidget { final Contact contact; diff --git a/lib/widgets/friend_request_message_field.dart b/lib/widgets/friend_request_message_field.dart index 933bf09..1ecb232 100644 --- a/lib/widgets/friend_request_message_field.dart +++ b/lib/widgets/friend_request_message_field.dart @@ -1,8 +1,7 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - final class FriendRequestMessageField extends StatelessWidget { final ToxConstants constants; final TextEditingController controller; diff --git a/lib/widgets/main_menu.dart b/lib/widgets/main_menu.dart index 1f063f3..beeab5f 100644 --- a/lib/widgets/main_menu.dart +++ b/lib/widgets/main_menu.dart @@ -1,5 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:btox/logger.dart'; import 'package:btox/pages/settings_page.dart'; import 'package:btox/pages/user_profile_page.dart'; @@ -7,7 +8,6 @@ import 'package:btox/widgets/connection_status_icon.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; const _logger = Logger(['MainMenu']); @@ -28,9 +28,7 @@ final class MainMenu extends StatelessWidget { return NavigationDrawer( children: [ DrawerHeader( - decoration: const BoxDecoration( - color: Colors.blue, - ), + decoration: const BoxDecoration(color: Colors.blue), child: ListTile( title: Text( profile.settings.nickname, diff --git a/lib/widgets/message_input.dart b/lib/widgets/message_input.dart new file mode 100644 index 0000000..9cba024 --- /dev/null +++ b/lib/widgets/message_input.dart @@ -0,0 +1,132 @@ +import 'package:btox/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +final class MessageInput extends HookWidget { + final String hintText; + final String replyingTo; + final Color buttonColor; + final void Function() onAdd; + final void Function(String) onSend; + final void Function() onTapCloseReply; + + const MessageInput({ + super.key, + required this.hintText, + this.replyingTo = '', + this.buttonColor = Colors.blue, + required this.onAdd, + required this.onSend, + required this.onTapCloseReply, + }); + + @override + Widget build(BuildContext context) { + final messageInputController = useTextEditingController(); + final messageInputFocus = useFocusNode(); + final sendMode = useState(false); + + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (replyingTo.isNotEmpty) + Container( + color: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Row( + children: [ + Icon(Icons.reply, color: buttonColor), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + AppLocalizations.of(context)!.re(replyingTo), + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + ), + ), + InkWell( + onTap: onTapCloseReply, + child: Icon( + Icons.close, + color: Theme.of(context).primaryColorLight, + ), + ), + ], + ), + ), + if (replyingTo.isNotEmpty) const Divider(height: 1), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Expanded( + child: TextField( + key: const Key('messageField'), + controller: messageInputController, + focusNode: messageInputFocus, + autofocus: true, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: 3, + onChanged: (value) => sendMode.value = value.isNotEmpty, + onSubmitted: (_) => _onSend( + messageInputController, + messageInputFocus, + ), + decoration: InputDecoration( + hintText: hintText, + hintMaxLines: 1, + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + fillColor: Theme.of(context).splashColor, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24.0), + borderSide: BorderSide.none, + ), + ), + ), + ), + IconButton( + color: buttonColor, + icon: sendMode.value + ? const Icon(Icons.send) + : const Icon(Icons.add_circle), + onPressed: () { + if (sendMode.value) { + _onSend( + messageInputController, + messageInputFocus, + ); + } else { + onAdd(); + } + }, + ), + ], + ), + ), + ], + ); + } + + void _onSend( + TextEditingController messageInputController, + FocusNode messageInputFocus, + ) { + final message = messageInputController.text; + if (message.isNotEmpty) { + onSend(message); + messageInputController.text = ''; + messageInputFocus.requestFocus(); + } + } +} diff --git a/lib/widgets/nickname_field.dart b/lib/widgets/nickname_field.dart index 3886120..a64b5ad 100644 --- a/lib/widgets/nickname_field.dart +++ b/lib/widgets/nickname_field.dart @@ -1,6 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; final class NicknameField extends StatelessWidget { final ToxConstants constants; diff --git a/lib/widgets/status_message_field.dart b/lib/widgets/status_message_field.dart index 9b271ed..4958a31 100644 --- a/lib/widgets/status_message_field.dart +++ b/lib/widgets/status_message_field.dart @@ -1,6 +1,6 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; final class StatusMessageField extends StatelessWidget { final ToxConstants constants; diff --git a/lib/widgets/tox_id_field.dart b/lib/widgets/tox_id_field.dart index e0ef5a2..cc33950 100644 --- a/lib/widgets/tox_id_field.dart +++ b/lib/widgets/tox_id_field.dart @@ -1,8 +1,7 @@ import 'package:btox/api/toxcore/tox.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - final class ToxIdField extends StatelessWidget { final ToxConstants constants; final TextEditingController controller; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 98a8393..78db22a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import file_picker import path_provider_foundation import sodium_libs import sqlcipher_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9194626..2604115 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - file_picker (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - path_provider_foundation (0.0.1): - Flutter @@ -16,6 +18,7 @@ PODS: - SQLCipher (~> 4.5.7) DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - sodium_libs (from `Flutter/ephemeral/.symlinks/plugins/sodium_libs/darwin`) @@ -26,6 +29,8 @@ SPEC REPOS: - SQLCipher EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos FlutterMacOS: :path: Flutter/ephemeral path_provider_foundation: @@ -36,6 +41,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqlcipher_flutter_libs/macos SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 sodium_libs: d5a8c0ec38806fe1cff3caf98c8319378da0bc1d diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 08c3ab1..cff5a4b 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7..38da9a9 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/pubspec.lock b/pubspec.lock index 5d255af..72802f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -206,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: "direct main" description: @@ -318,6 +326,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "6f6bfa8797f296965bdc3e1f702574ab49a540c19b9237b401e7c2b25dfe594c" + url: "https://pub.dev" + source: hosted + version: "9.0.0" fixnum: dependency: transitive description: @@ -360,6 +376,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + url: "https://pub.dev" + source: hosted + version: "2.0.24" flutter_riverpod: dependency: "direct main" description: @@ -1015,6 +1039,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + win32: + dependency: transitive + description: + name: win32 + sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef + url: "https://pub.dev" + source: hosted + version: "5.11.0" xdg_directories: dependency: transitive description: @@ -1048,5 +1080,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.7.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 14c0404..15ff04c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: cupertino_icons: ^1.0.2 drift: ^2.21.0 ffi: ^2.1.3 + file_picker: ^9.0.0 flutter_hooks: ^0.20.5 flutter_riverpod: ^2.6.1 freezed_annotation: ^2.4.4 diff --git a/test/btox_app_test.dart b/test/btox_app_test.dart index 2996465..b1807d0 100644 --- a/test/btox_app_test.dart +++ b/test/btox_app_test.dart @@ -21,7 +21,7 @@ import 'mocks/fake_toxcore.dart'; // Drift's timers being created during the test and then only torn down during // the tearDown after being reported as leaks at the test end. void main() { - final mySecretKey = SecretKey.fromString( + final mySecretKey = SecretKey.fromJson( String.fromCharCodes(Iterable.generate(64, (_) => 'F'.codeUnits.first))); final myToxId = ToxAddress.fromString( String.fromCharCodes(Iterable.generate(76, (_) => '0'.codeUnits.first))); diff --git a/test/chat_page_test.dart b/test/chat_page_test.dart new file mode 100644 index 0000000..29ade4b --- /dev/null +++ b/test/chat_page_test.dart @@ -0,0 +1,182 @@ +import 'package:btox/db/database.dart'; +import 'package:btox/l10n/generated/app_localizations.dart'; +import 'package:btox/models/crypto.dart'; +import 'package:btox/models/id.dart'; +import 'package:btox/models/messaging.dart'; +import 'package:btox/models/persistence.dart'; +import 'package:btox/models/profile_settings.dart'; +import 'package:btox/pages/chat_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final profile = Profile( + id: Id(0), + active: true, + settings: ProfileSettings( + nickname: 'Yeetman', statusMessage: 'Yeeting everyone.'), + secretKey: SecretKey.fromJson( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + publicKey: PublicKey.fromJson( + '0000000000000000000000000000000000000000000000000000000000000000'), + nospam: ToxAddressNospam(0), + ); + + final contact = Contact( + id: Id(1), + profileId: profile.id, + name: 'Testman', + publicKey: PublicKey.fromJson( + '1111111111111111111111111111111111111111111111111111111111111111'), + ); + + testWidgets('Empty chat page should display message entry box', + (WidgetTester tester) async { + final messages = []; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/empty.png')); + }); + + testWidgets('Chat page should display messages', (WidgetTester tester) async { + final messages = []; + + messages.add(_fakeInsertMessage( + Id(0), + newMessage( + contactId: contact.id, + parent: null, + merged: null, + author: contact.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 1, 12), + content: 'Happy New Year!', + ), + )); + messages.add(_fakeInsertMessage( + Id(1), + newMessage( + contactId: contact.id, + parent: messages.last, + merged: null, + author: profile.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 2, 23), + content: 'Thank you! Happy New Year to you too!', + ), + )); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/messages.png')); + }); + + testWidgets('Long messages should be shown in a big bubble', + (WidgetTester tester) async { + final messages = []; + + messages.add(_fakeInsertMessage( + Id(0), + newMessage( + contactId: contact.id, + parent: null, + merged: null, + author: profile.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 1, 12), + content: 'Here is a long message.\n' * 10, + ), + )); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/messages_long.png')); + }); + + testWidgets('Tapping a message shows the time', (WidgetTester tester) async { + final messages = []; + + messages.add(_fakeInsertMessage( + Id(0), + newMessage( + contactId: contact.id, + parent: null, + merged: null, + author: contact.publicKey, + timestamp: DateTime(2025, 1, 1, 0, 1, 12), + content: 'Happy New Year!', + ), + )); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: ChatPage( + profile: profile, + contact: Stream.value(contact), + messages: Stream.value(messages), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap the message. + // TODO(iphydf): This is a hack. Find a better way to tap the message. + final bubble = find.byType(GestureDetector).first.evaluate().first.widget + as GestureDetector; + bubble.onTap!(); + + await tester.pumpAndSettle(); + + await expectLater(find.byType(ChatPage), + matchesGoldenFile('goldens/chat_page/messages_time.png')); + }); +} + +Message _fakeInsertMessage(Id id, MessagesCompanion message) { + return Message( + id: id, + contactId: message.contactId.value, + parent: message.parent.value, + merged: message.merged.value, + author: message.author.value, + sha: message.sha.value, + timestamp: message.timestamp.value, + content: message.content.value, + ); +} diff --git a/test/goldens/chat_empty.png b/test/goldens/chat_empty.png index 9812db350ff6f806ba291cf232fa769b2ec77048..afd9e39f216dc0828c5c5e6adb50353bc2cb8f20 100644 GIT binary patch literal 40479 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%0A)?1X@>7VKR%(GSq4p8$a{SH-@L*zkeS9oEhwk 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 b7~U~<`27vJXPn2ddR;uPd@kwCtw;X>RygUn diff --git a/test/goldens/chat_hello.png b/test/goldens/chat_hello.png index 87e15e823331153eb6cd7a65a8f750b9e242c7fe..234eca9005756ec02cd1f6573b979298d57079b5 100644 GIT binary patch literal 45194 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 literal 32348 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 diff --git a/test/goldens/chat_page/empty.png b/test/goldens/chat_page/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..115808b84c5337f61177c3072e687f805a8e16aa GIT binary patch literal 4453 zcmeI0Sx{3~7{^Zlo74(|D6)pMqGK%zRD#N)tOAu#R5YLU0s(sHyKQaXI=L_R-gD2l{qFyqneSZk@^n>S zt-l%opziMGd#!fM?5d$JUPT$QNgV%#?xavI^JNxT4gBVpc{~j@&+N-Tp~>iN5xQ3Cv7$@?Ad_cXRbj*y3P1o5q=o`O zbCW$B*F~rR@C>C10A@cKPOwl>10YQc1AzUNznd!K?08qykYxGF zk05BY#;B5HK3*uVHXvfxIH^A^BR=&S*o{uG1t9M_HI&fFB+?-163-`+@N&7F7nxXq0FMraWl?oxF1a~5ucv~2!^h0q*lEZY<{H)A zZ@gn;zp@4(?#C=@G4I@wv7uvW3WrLd$EmauP_1eV`k;AYL3q}&($4A=WkxLtcihMl zBAfMazL?1t)RSOm(l27f`{x9_K5Jz&z11~RiZ~|}cG2n0`jP|y-l4^7Z{CTv+8TNjY7xKt(Lgl{JgHI=TohPhNX3O799fO`OSM_KKmg7d{;S*J+5K* zvaRO;d758ewhO=DqYuEfw=*Kzd^l;u;#&J4;_Edq8d> z`$`qbELKUNF4a#5g3AfiJ+U14Hb6uQZ3o?NA_#1OXRQeUw!VwR{A2pPjKmt0Q7dn)Z`A|3&cuUwHNwY{3rz|K2cdXFO| zTW-_%)1gcaQRQQspbc}&TP5de8$P!7eRvuMFo)MULBU_tKuf_W0RBOt-5U~ zQzJ&1@UumL$4+&KxCn|Ei;Ety&?wVmaU%U=YW)2Lez=`&$Lke}1sZK$oc*o53d+YM z2dautYdx)t+|_j0+{_bQ^D|6TY9s$`U$%*T53{smW_1056>Sg4To4;z%5lUEoz2zh zP|lH|d}e3!3>1U@PqwO4`(xR~^z5QJhxGvbPLXvmj(wWBd^@H&sM@cq>4`xl0C#AF zN}sfh8js`#kZMzaOU6wv|`4lXjP7M|JE1v9ZZwNBO z2k*`A>Yh(BKA;L7wJr2Y9*SghF!ncz>}`>ItIpNcmPN6K-$1C}VR46ti_XmS2gi>b zyw-Uy^6@-&ky+jh7g}my{yx}>Be7xp`Ci3cooKVu^V<{GX3}Va3OXKtlNVXDu=FMT zm)R}DGktTEytz2C8`=BFRqWANJ})HtaLOiMh!@#IEuIlSI7&Z)9op`(R_n9OT~IIC z6I2$T%lI6w6L(z4>x5bwoTFb(T6F41Of{NI#Is9f6H+N%I2)t}$c3GjIJg~I@7Ys4 z)YKkDkBzASbH7Fz!p}VUh4EB>^`~A>E9e4qlr{i&;iluuKMHjd&d;i595dCazGWsM vdh~8xkyRX8VgOGTKIZsf#q6(68WbDRD&p8**rn@WYY*IAJe|uNPhR{3F=Rg8 literal 0 HcmV?d00001 diff --git a/test/goldens/chat_page/messages.png b/test/goldens/chat_page/messages.png new file mode 100644 index 0000000000000000000000000000000000000000..16dabc5b35b5ad1452b35419cfe1283226771f0b GIT binary patch literal 6199 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 E1J|iXo&W#< literal 0 HcmV?d00001 diff --git a/test/goldens/chat_page/messages_long.png b/test/goldens/chat_page/messages_long.png new file mode 100644 index 0000000000000000000000000000000000000000..02cadef6ee5fa6e7dfef1920a3aea9df22101bb8 GIT binary patch literal 6369 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 axNO+Q^|^xa{1Y?>0Iy>{M=KA1^yyDH^Gjv` literal 0 HcmV?d00001 diff --git a/test/persistence_test.dart b/test/persistence_test.dart index 3950d5d..c285a5c 100644 --- a/test/persistence_test.dart +++ b/test/persistence_test.dart @@ -7,7 +7,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - final mySecretKey = SecretKey.fromString( + final mySecretKey = SecretKey.fromJson( String.fromCharCodes(Iterable.generate(64, (_) => 'F'.codeUnits.first))); final myToxId = ToxAddress.fromString( String.fromCharCodes(Iterable.generate(76, (_) => '0'.codeUnits.first))); @@ -44,7 +44,8 @@ void main() { final firstMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', ))); @@ -56,7 +57,8 @@ void main() { final secondMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: firstMsg, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2026, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', ))); @@ -95,7 +97,8 @@ void main() { final myFirstMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, // 2 minutes after midnight. timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', @@ -105,7 +108,8 @@ void main() { final friendFirstMsg = await db.getMessage(await db.addMessage(newMessage( contactId: contactId, parent: null, - origin: friendPk, + merged: null, + author: friendPk, // 1 minute before my message. timestamp: myFirstMsg.timestamp.subtract(const Duration(minutes: 1)), content: 'Happy new year!', @@ -115,7 +119,7 @@ void main() { contactId: contactId, parent: myFirstMsg, merged: friendFirstMsg, - origin: myToxId.publicKey, + author: myToxId.publicKey, // 1 minute after my message. timestamp: myFirstMsg.timestamp.add(const Duration(minutes: 1)), content: 'Haha, jinx!', @@ -129,7 +133,8 @@ void main() { final msg1 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123, 456), content: 'Happy new year!', ); @@ -137,7 +142,8 @@ void main() { final msg2 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: msg1.timestamp.value.add(const Duration(microseconds: 100)), content: 'Happy new year!', ); @@ -149,7 +155,8 @@ void main() { final msg1 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: DateTime(2025, 1, 1, 0, 2, 10, 123), content: 'Happy new year!', ); @@ -157,7 +164,8 @@ void main() { final msg2 = newMessage( contactId: Id(1), parent: null, - origin: myToxId.publicKey, + merged: null, + author: myToxId.publicKey, timestamp: msg1.timestamp.value.add(const Duration(microseconds: 1000)), content: 'Happy new year!', );