mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 07:48:40 +00:00
sending text messages kinda works
This commit is contained in:
parent
f866e4315e
commit
e0d420b78d
14 changed files with 225 additions and 77 deletions
|
|
@ -24,12 +24,7 @@ class MessageSendStateIcon extends StatelessWidget {
|
|||
Widget icon = Placeholder();
|
||||
String text = "";
|
||||
|
||||
Color color = Theme.of(context).colorScheme.primary;
|
||||
if (kind == MessageKind.textMessage) {
|
||||
color = Colors.lightBlue;
|
||||
} else if (kind == MessageKind.video) {
|
||||
color = Colors.deepPurple;
|
||||
}
|
||||
Color color = kind.getColor(Theme.of(context).colorScheme.primary);
|
||||
|
||||
switch (state) {
|
||||
case MessageSendState.receivedOpened:
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@
|
|||
"searchUsernameNotFound": "Username not found",
|
||||
"searchUsernameNewFollowerTitle": "Follow requests",
|
||||
"searchUsernameQrCodeBtn": "Scan QR code",
|
||||
"chatListViewSearchUserNameBtn": "Add user",
|
||||
"chatListViewSearchUserNameBtn": "Add your first twonly contact!",
|
||||
"chatListViewSendFirstTwonly": "Send your first twonly!",
|
||||
"chatListDetailInput": "Type a message",
|
||||
"chatListDetailTitle": "Your chat with {username}",
|
||||
"searchUsernameNotFoundLong": "\"{username}\" is not a twonly user. Please check the username and try again.",
|
||||
"errorUnknown": "An unexpected error has occurred. Please try again later.",
|
||||
"errorBadRequest": "The request could not be understood by the server due to malformed syntax. Please check your input and try again.",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:twonly/src/utils/json.dart';
|
||||
part 'message.g.dart';
|
||||
|
|
@ -9,6 +10,7 @@ enum MessageKind {
|
|||
contactRequest,
|
||||
rejectRequest,
|
||||
acceptRequest,
|
||||
opened,
|
||||
ack
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +26,16 @@ extension MessageKindExtension on MessageKind {
|
|||
static MessageKind fromIndex(int index) {
|
||||
return MessageKind.values[index];
|
||||
}
|
||||
|
||||
Color getColor(Color primary) {
|
||||
Color color = primary;
|
||||
if (this == MessageKind.textMessage) {
|
||||
color = Colors.lightBlue;
|
||||
} else if (this == MessageKind.video) {
|
||||
color = Colors.deepPurple;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
// so _$MessageKindEnumMap gets generated
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const _$MessageKindEnumMap = {
|
|||
MessageKind.contactRequest: 'contactRequest',
|
||||
MessageKind.rejectRequest: 'rejectRequest',
|
||||
MessageKind.acceptRequest: 'acceptRequest',
|
||||
MessageKind.opened: 'opened',
|
||||
MessageKind.ack: 'ack',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:cv/cv.dart';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:twonly/main.dart';
|
||||
import 'package:twonly/src/app.dart';
|
||||
import 'package:twonly/src/components/message_send_state_icon.dart';
|
||||
import 'package:twonly/src/model/json/message.dart';
|
||||
import 'package:twonly/src/providers/api/api.dart';
|
||||
|
||||
|
|
@ -37,6 +38,30 @@ class DbMessage {
|
|||
if (messageOtherId == null) return false;
|
||||
return messageKind == MessageKind.image || messageKind == MessageKind.video;
|
||||
}
|
||||
|
||||
MessageSendState getSendState() {
|
||||
MessageSendState state;
|
||||
if (!messageAcknowledgeByServer) {
|
||||
state = MessageSendState.sending;
|
||||
} else {
|
||||
if (messageOtherId == null) {
|
||||
// message send
|
||||
if (messageOpenedAt == null) {
|
||||
state = MessageSendState.send;
|
||||
} else {
|
||||
state = MessageSendState.sendOpened;
|
||||
}
|
||||
} else {
|
||||
// message received
|
||||
if (messageOpenedAt == null) {
|
||||
state = MessageSendState.received;
|
||||
} else {
|
||||
state = MessageSendState.receivedOpened;
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class DbMessages extends CvModelBase {
|
||||
|
|
@ -179,6 +204,12 @@ class DbMessages extends CvModelBase {
|
|||
|
||||
List<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
|
||||
List<DbMessage> notAckByServer =
|
||||
messages.where((c) => !c.messageAcknowledgeByServer).toList();
|
||||
|
|
@ -198,7 +229,7 @@ class DbMessages extends CvModelBase {
|
|||
await dbProvider.db!.update(
|
||||
tableName,
|
||||
data,
|
||||
where: "$messageId = ?",
|
||||
where: "$columnMessageId = ?",
|
||||
whereArgs: [messageId],
|
||||
);
|
||||
int? fromUserId = await getFromUserIdByMessageId(messageId);
|
||||
|
|
@ -207,6 +238,18 @@ class DbMessages extends CvModelBase {
|
|||
}
|
||||
}
|
||||
|
||||
// this ensures that the message id can be spoofed by another person
|
||||
static Future _updateByMessageIdOther(
|
||||
int fromUserId, int messageId, Map<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 {
|
||||
Map<String, dynamic> data = {
|
||||
columnMessageOpenedAt: DateTime.now().toIso8601String(),
|
||||
|
|
@ -214,6 +257,14 @@ class DbMessages extends CvModelBase {
|
|||
await _updateByMessageId(messageId, data);
|
||||
}
|
||||
|
||||
static Future userOpenedMessageOtherUser(
|
||||
int fromUserId, int messageId, DateTime openedAt) async {
|
||||
Map<String, dynamic> data = {
|
||||
columnMessageOpenedAt: openedAt.toIso8601String(),
|
||||
};
|
||||
await _updateByMessageIdOther(fromUserId, messageId, data);
|
||||
}
|
||||
|
||||
static Future acknowledgeMessageByServer(int messageId) async {
|
||||
Map<String, dynamic> data = {
|
||||
columnMessageAcknowledgeByServer: 1,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,24 @@ Future<Result> encryptAndSendMessage(Int64 userId, Message msg) async {
|
|||
return resp;
|
||||
}
|
||||
|
||||
Future sendTextMessage(Int64 target, String message) async {
|
||||
MessageContent content = MessageContent(text: message, downloadToken: null);
|
||||
|
||||
int? messageId = await DbMessages.insertMyMessage(
|
||||
target.toInt(), MessageKind.textMessage,
|
||||
jsonContent: jsonEncode(content.toJson()));
|
||||
if (messageId == null) return;
|
||||
|
||||
Message msg = Message(
|
||||
kind: MessageKind.textMessage,
|
||||
messageId: messageId,
|
||||
content: content,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
await encryptAndSendMessage(target, msg);
|
||||
}
|
||||
|
||||
Future sendImageToSingleTarget(Int64 target, Uint8List imageBytes) async {
|
||||
int? messageId =
|
||||
await DbMessages.insertMyMessage(target.toInt(), MessageKind.image);
|
||||
|
|
@ -118,15 +136,28 @@ Future tryDownloadMedia(List<int> imageToken, {bool force = false}) async {
|
|||
apiProvider.triggerDownload(imageToken);
|
||||
}
|
||||
|
||||
Future userOpenedMessage(int fromUserId, int messageId) async {
|
||||
await DbMessages.userOpenedMessage(messageId);
|
||||
|
||||
encryptAndSendMessage(
|
||||
Int64(fromUserId),
|
||||
Message(
|
||||
kind: MessageKind.opened,
|
||||
messageId: messageId,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> getDownloadedMedia(
|
||||
List<int> mediaToken, int messageId) async {
|
||||
final box = await getMediaStorage();
|
||||
Uint8List? media = box.get("${mediaToken}_downloaded");
|
||||
// box.delete(mediaToken.toString());
|
||||
// box.delete("${mediaToken}_downloaded");
|
||||
// box.delete("${mediaToken}_fromUserId");
|
||||
// await DbMessages.userOpenedMessage(messageId);
|
||||
|
||||
int fromUserId = box.get("${mediaToken}_fromUserId");
|
||||
await userOpenedMessage(fromUserId, messageId);
|
||||
box.delete(mediaToken.toString());
|
||||
box.put("${mediaToken}_downloaded", "deleted");
|
||||
box.delete("${mediaToken}_fromUserId");
|
||||
return media;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,13 @@ Future<client.Response> handleNewMessage(
|
|||
utf8.decode(name), fromUserId.toInt(), true);
|
||||
}
|
||||
break;
|
||||
case MessageKind.opened:
|
||||
await DbMessages.userOpenedMessageOtherUser(
|
||||
fromUserId.toInt(),
|
||||
message.messageId!,
|
||||
message.timestamp,
|
||||
);
|
||||
break;
|
||||
case MessageKind.rejectRequest:
|
||||
DbContacts.deleteUser(fromUserId.toInt());
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ class ApiProvider {
|
|||
|
||||
final result = await _sendRequestV0(req);
|
||||
if (result.isError) {
|
||||
log.shout(result);
|
||||
log.shout("Error auth", result);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ class ApiProvider {
|
|||
|
||||
final result2 = await _sendRequestV0(req2);
|
||||
if (result2.isError) {
|
||||
log.shout(result2);
|
||||
log.shout("send request failed: ${result2.error}");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import 'package:twonly/src/model/messages_model.dart';
|
|||
/// for every contact.
|
||||
class MessagesChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
||||
final Map<int, DbMessage> _lastMessage = <int, DbMessage>{};
|
||||
|
||||
Map<int, DbMessage> get lastMessage => _lastMessage;
|
||||
|
||||
void updateLastMessageFor(int targetUserId) async {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
});
|
||||
}
|
||||
},
|
||||
onDoubleTap: () async {
|
||||
cameraState.switchCameraSensor(
|
||||
aspectRatio: CameraAspectRatios.ratio_16_9);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
import 'package:cv/cv.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:twonly/src/components/message_send_state_icon.dart';
|
||||
import 'package:twonly/src/model/contacts_model.dart';
|
||||
import 'package:twonly/src/model/json/message.dart';
|
||||
import 'package:twonly/src/model/messages_model.dart';
|
||||
import 'package:twonly/src/providers/api/api.dart';
|
||||
import 'package:twonly/src/providers/messages_change_provider.dart';
|
||||
import 'package:twonly/src/views/media_viewer_view.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class ChatListEntry extends StatelessWidget {
|
||||
const ChatListEntry(this.message, {super.key});
|
||||
const ChatListEntry(this.message, this.user, this.lastMessageFromSameUser,
|
||||
{super.key});
|
||||
final DbMessage message;
|
||||
final Contact user;
|
||||
final bool lastMessageFromSameUser;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool right = message.messageOtherId == null;
|
||||
|
||||
MessageSendState state = message.getSendState();
|
||||
|
||||
Widget child = Container();
|
||||
|
||||
switch (message.messageKind) {
|
||||
|
|
@ -38,13 +50,52 @@ class ChatListEntry extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
break;
|
||||
case MessageKind.image:
|
||||
Color color =
|
||||
message.messageKind.getColor(Theme.of(context).colorScheme.primary);
|
||||
child = GestureDetector(
|
||||
onTap: () {
|
||||
if (state == MessageSendState.received) {
|
||||
if (message.isDownloaded) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) {
|
||||
return MediaViewerView(user, message);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
List<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:
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: right ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Padding(padding: EdgeInsets.all(10), child: child),
|
||||
child: Padding(
|
||||
padding: lastMessageFromSameUser
|
||||
? EdgeInsets.only(top: 5, bottom: 0, right: 10, left: 10)
|
||||
: EdgeInsets.all(10),
|
||||
child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +112,7 @@ class ChatItemDetailsView extends StatefulWidget {
|
|||
|
||||
class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
|
||||
List<DbMessage> _messages = [];
|
||||
final TextEditingController newMessageController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -73,8 +125,23 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
|
|||
await DbMessages.getAllMessagesForUser(widget.user.userId.toInt());
|
||||
}
|
||||
|
||||
Future _sendMessage() async {
|
||||
String text = newMessageController.text;
|
||||
if (text == "") return;
|
||||
sendTextMessage(widget.user.userId, newMessageController.text);
|
||||
newMessageController.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messages = context.watch<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();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -87,7 +154,16 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
|
|||
itemCount: _messages.length, // Number of items in the list
|
||||
reverse: true,
|
||||
itemBuilder: (context, i) {
|
||||
return ChatListEntry(_messages[i]);
|
||||
bool lastMessageFromSameUser = false;
|
||||
if (i > 0) {
|
||||
lastMessageFromSameUser =
|
||||
(_messages[i - 1].messageOtherId == null &&
|
||||
_messages[i].messageOtherId == null) ||
|
||||
(_messages[i - 1].messageOtherId != null &&
|
||||
_messages[i].messageOtherId != null);
|
||||
}
|
||||
return ChatListEntry(
|
||||
_messages[i], widget.user, lastMessageFromSameUser);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -98,9 +174,13 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
|
|||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
// controller: _controller,
|
||||
controller: newMessageController,
|
||||
onSubmitted: (_) {
|
||||
_sendMessage();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message',
|
||||
hintText:
|
||||
AppLocalizations.of(context)!.chatListDetailInput,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 10)
|
||||
// border: OutlineInputBorder(),
|
||||
),
|
||||
|
|
@ -110,31 +190,12 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
|
|||
IconButton(
|
||||
icon: FaIcon(FontAwesomeIcons.solidPaperPlane),
|
||||
onPressed: () {
|
||||
// Handle send action
|
||||
_sendMessage();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Container(
|
||||
// child: Row(children: [
|
||||
// Padding(
|
||||
// padding: const EdgeInsets.only(bottom: 40, left: 10, right: 10),
|
||||
// child: TextField(
|
||||
// decoration: InputDecoration(
|
||||
// // border: OutlineInputBorder(),
|
||||
// hintText: 'Enter your message',
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// IconButton(
|
||||
// icon: FaIcon(FontAwesomeIcons.solidPaperPlane),
|
||||
// onPressed: () {
|
||||
// // Handle send action
|
||||
// },
|
||||
// ),
|
||||
// ]),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
Map<int, DbMessage> lastMessages =
|
||||
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
|
||||
.where((x) => lastMessages.containsKey(x.userId.toInt()))
|
||||
|
|
@ -80,11 +84,11 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: OutlinedButton.icon(
|
||||
icon: Icon((allUsers.isEmpty)
|
||||
icon: Icon((activeUsers.isEmpty)
|
||||
? Icons.person_add
|
||||
: Icons.camera_alt),
|
||||
onPressed: () {
|
||||
(allUsers.isEmpty)
|
||||
(activeUsers.isEmpty)
|
||||
? Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -93,7 +97,7 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
)
|
||||
: globalUpdateOfHomeViewPageIndex(1);
|
||||
},
|
||||
label: Text((allUsers.isEmpty)
|
||||
label: Text((activeUsers.isEmpty)
|
||||
? AppLocalizations.of(context)!
|
||||
.chatListViewSearchUserNameBtn
|
||||
: AppLocalizations.of(context)!
|
||||
|
|
@ -140,30 +144,11 @@ class _UserListItem extends State<UserListItem> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
MessageSendState state;
|
||||
int lastMessageInSeconds = DateTime.now()
|
||||
.difference(widget.lastMessage.sendOrReceivedAt)
|
||||
.inSeconds;
|
||||
|
||||
if (!widget.lastMessage.messageAcknowledgeByServer) {
|
||||
state = MessageSendState.sending;
|
||||
} else {
|
||||
if (widget.lastMessage.messageOtherId == null) {
|
||||
// message send
|
||||
if (widget.lastMessage.messageOpenedAt == null) {
|
||||
state = MessageSendState.send;
|
||||
} else {
|
||||
state = MessageSendState.sendOpened;
|
||||
}
|
||||
} else {
|
||||
// message received
|
||||
if (widget.lastMessage.messageOpenedAt == null) {
|
||||
state = MessageSendState.received;
|
||||
} else {
|
||||
state = MessageSendState.receivedOpened;
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageSendState state = widget.lastMessage.getSendState();
|
||||
|
||||
return UserContextMenu(
|
||||
user: widget.user,
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
Future _initAsync() async {
|
||||
List<int> token = widget.message.messageContent!.downloadToken!;
|
||||
|
||||
_imageByte = await getDownloadedMedia(token, widget.message.messageId);
|
||||
print(_imageByte);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +51,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
_imageByte!,
|
||||
fit: BoxFit.contain,
|
||||
)
|
||||
: CircularProgressIndicator()),
|
||||
: Container()),
|
||||
),
|
||||
),
|
||||
_imageByte != null
|
||||
|
|
|
|||
|
|
@ -84,14 +84,16 @@ class _SearchUsernameView extends State<SearchUsernameView> {
|
|||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: TextField(
|
||||
onSubmitted: (_) {
|
||||
_addNewUser(context);
|
||||
},
|
||||
controller: searchUserName,
|
||||
decoration: getInputDecoration(
|
||||
AppLocalizations.of(context)!.searchUsernameInput))),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: TextField(
|
||||
onSubmitted: (_) {
|
||||
_addNewUser(context);
|
||||
},
|
||||
controller: searchUserName,
|
||||
decoration: getInputDecoration(
|
||||
AppLocalizations.of(context)!.searchUsernameInput),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
icon: Icon(Icons.qr_code),
|
||||
|
|
|
|||
Loading…
Reference in a new issue