Quoted Messages Initial Pass

This commit is contained in:
Sarah Jamie Lewis 2021-07-05 12:31:16 -07:00
parent 370a7fd1da
commit b9984a3598
11 changed files with 273 additions and 109 deletions

View File

@ -122,6 +122,12 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
val indexI = a.getInt("index")
return Result.success(Data.Builder().putString("result", Cwtch.getMessage(profile, handle, indexI.toLong())).build())
}
"GetMessageByContentHash" -> {
val profile = (a.get("profile") as? String) ?: ""
val handle = (a.get("contact") as? String) ?: ""
val contentHash = (a.get("contentHash") as? String) ?: ""
return Result.success(Data.Builder().putString("result", Cwtch.getMessageByContentHash(profile, handle, contentHash)).build())
}
"UpdateMessageFlags" -> {
val profile = (a.get("profile") as? String) ?: ""
val handle = (a.get("contact") as? String) ?: ""

View File

@ -32,6 +32,8 @@ abstract class Cwtch {
// ignore: non_constant_identifier_names
Future<dynamic> GetMessage(String profile, String handle, int index);
// ignore: non_constant_identifier_names
Future<dynamic> GetMessageByContentHash(String profile, String handle, String contentHash);
// ignore: non_constant_identifier_names
void UpdateMessageFlags(String profile, String handle, int index, int flags);
// ignore: non_constant_identifier_names
void SendMessage(String profile, String handle, String message);

View File

@ -57,9 +57,9 @@ typedef GetIntFromStrStrFn = int Function(Pointer<Utf8>, int, Pointer<Utf8>, int
typedef get_json_blob_from_str_str_int_function = Pointer<Utf8> Function(Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Int32);
typedef GetJsonBlobFromStrStrIntFn = Pointer<Utf8> Function(Pointer<Utf8>, int, Pointer<Utf8>, int, int);
//func GetMessages(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, start C.int, end C.int) *C.char {
typedef get_json_blob_from_str_str_int_int_function = Pointer<Utf8> Function(Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Int32, Int32);
typedef GetJsonBlobFromStrStrIntIntFn = Pointer<Utf8> Function(Pointer<Utf8>, int, Pointer<Utf8>, int, int, int);
// func c_GetMessagesByContentHash(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, contenthash_ptr *C.char, contenthash_len C.int) *C.char
typedef get_json_blob_from_str_str_str_function = Pointer<Utf8> Function(Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Pointer<Utf8>, Int32);
typedef GetJsonBlobFromStrStrStrFn = Pointer<Utf8> Function(Pointer<Utf8>, int, Pointer<Utf8>, int, Pointer<Utf8>, int);
typedef appbus_events_function = Pointer<Utf8> Function();
typedef AppbusEventsFn = Pointer<Utf8> Function();
@ -397,4 +397,17 @@ class CwtchFfi implements Cwtch {
_receivePort.close();
print("Receive Port Closed");
}
@override
Future GetMessageByContentHash(String profile, String handle, String contentHash) async {
var getMessagesByContentHashC = library.lookup<NativeFunction<get_json_blob_from_str_str_str_function>>("c_GetMessagesByContentHash");
// ignore: non_constant_identifier_names
final GetMessagesByContentHash = getMessagesByContentHashC.asFunction<GetJsonBlobFromStrStrStrFn>();
final utf8profile = profile.toNativeUtf8();
final utf8handle = handle.toNativeUtf8();
final utf8contentHash = contentHash.toNativeUtf8();
Pointer<Utf8> jsonMessageBytes = GetMessagesByContentHash(utf8profile, utf8profile.length, utf8handle, utf8handle.length, utf8contentHash, utf8contentHash.length);
String jsonMessage = jsonMessageBytes.toDartString();
return jsonMessage;
}
}

View File

@ -183,4 +183,9 @@ class CwtchGomobile implements Cwtch {
print("gomobile.dart Shutdown");
cwtchPlatform.invokeMethod("Shutdown", {});
}
@override
Future GetMessageByContentHash(String profile, String handle, String contentHash) {
return cwtchPlatform.invokeMethod("GetMessageByContentHash", {"profile": profile, "contact": handle, "contentHash": contentHash});
}
}

View File

@ -67,6 +67,7 @@ class AppState extends ChangeNotifier {
String appError = "";
String? _selectedProfile;
String? _selectedConversation;
int? _selectedIndex;
void SetCwtchInit() {
cwtchInit = true;
@ -90,6 +91,12 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
int? get selectedIndex => _selectedIndex;
set selectedIndex(int? newVal) {
this._selectedIndex = newVal;
notifyListeners();
}
bool isLandscape(BuildContext c) => MediaQuery.of(c).size.width > MediaQuery.of(c).size.height;
}
@ -457,7 +464,7 @@ class MessageState extends ChangeNotifier {
final String profileOnion;
final String contactHandle;
final int messageIndex;
late String _message;
late dynamic _message;
late int _overlay;
late String _inviteTarget;
late String _inviteNick;
@ -535,7 +542,7 @@ class MessageState extends ChangeNotifier {
return;
}
dynamic message = jsonDecode(messageWrapper['Message']);
this._message = message['d'];
this._message = message['d'] as dynamic;
this._overlay = int.parse(message['o'].toString());
this._timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!;
this._senderOnion = messageWrapper['PeerID'];

View File

@ -1,7 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:cwtch/cwtch_icons_icons.dart';
import 'package:cwtch/widgets/malformedbubble.dart';
import 'package:cwtch/widgets/profileimage.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@ -104,11 +105,33 @@ class _MessageViewState extends State<MessageView> {
void _sendMessage([String? ignoredParam]) {
if (ctrlrCompose.value.text.isNotEmpty) {
ChatMessage cm = new ChatMessage(o: 1, d: ctrlrCompose.value.text);
Provider.of<FlwtchState>(context, listen: false)
.cwtch
.SendMessage(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).onion, jsonEncode(cm));
_sendMessageHelper();
if (Provider.of<AppState>(context).selectedConversation != null && Provider.of<AppState>(context).selectedIndex != null) {
Provider.of<FlwtchState>(context)
.cwtch
.GetMessage(Provider.of<AppState>(context).selectedProfile!, Provider.of<AppState>(context).selectedConversation!, Provider.of<AppState>(context).selectedIndex!).then((data) {
try {
var messageWrapper = jsonDecode(data! as String);
var bytes1 = utf8.encode(messageWrapper["PeerID"]+messageWrapper['Message']);
var digest1 = sha256.convert(bytes1);
var contentHash = base64Encode(digest1.bytes);
var quotedMessage = "{\"quotedHash\":\""+contentHash+"\",\"body\":\""+ctrlrCompose.value.text+"\"}";
ChatMessage cm = new ChatMessage(o: 10, d: quotedMessage);
Provider.of<FlwtchState>(context, listen: false)
.cwtch
.SendMessage(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).onion, jsonEncode(cm));
} catch (e) {
}
Provider.of<AppState>(context, listen: false).selectedIndex = null;
_sendMessageHelper();
});
} else {
ChatMessage cm = new ChatMessage(o: 1, d: ctrlrCompose.value.text);
Provider.of<FlwtchState>(context, listen: false)
.cwtch
.SendMessage(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).onion, jsonEncode(cm));
_sendMessageHelper();
}
}
}
@ -130,7 +153,7 @@ class _MessageViewState extends State<MessageView> {
}
Widget _buildComposeBox() {
return Container(
var composeBox = Container(
color: Provider.of<Settings>(context).theme.backgroundMainColor(),
padding: EdgeInsets.all(2),
margin: EdgeInsets.all(2),
@ -141,52 +164,83 @@ class _MessageViewState extends State<MessageView> {
child: Container(
decoration: BoxDecoration(border: Border(top: BorderSide(color: Provider.of<Settings>(context).theme.defaultButtonActiveColor()))),
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: handleKeyPress,
child: TextFormField(
key: Key('txtCompose'),
controller: ctrlrCompose,
focusNode: focusNode,
autofocus: !Platform.isAndroid,
textInputAction: TextInputAction.newline,
keyboardType: TextInputType.multiline,
minLines: 1,
maxLines: null,
onFieldSubmitted: _sendMessage,
decoration: InputDecoration(
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
enabled: true,
prefixIcon: IconButton(
icon: Icon(CwtchIcons.send_invite, size: 24, color: Provider.of<Settings>(context).theme.mainTextColor()),
tooltip: AppLocalizations.of(context)!.sendInvite,
enableFeedback: true,
splashColor: Provider.of<Settings>(context).theme.defaultButtonActiveColor(),
hoverColor: Provider.of<Settings>(context).theme.defaultButtonActiveColor(),
onPressed: () => _modalSendInvitation(context)),
suffixIcon: IconButton(
icon: Icon(CwtchIcons.send_24px, size: 24, color: Provider.of<Settings>(context).theme.mainTextColor()),
tooltip: AppLocalizations.of(context)!.sendMessage,
onPressed: _sendMessage,
),
)))),
focusNode: FocusNode(),
onKey: handleKeyPress,
child: TextFormField(
key: Key('txtCompose'),
controller: ctrlrCompose,
focusNode: focusNode,
autofocus: !Platform.isAndroid,
textInputAction: TextInputAction.newline,
keyboardType: TextInputType.multiline,
minLines: 1,
maxLines: null,
onFieldSubmitted: _sendMessage,
decoration: InputDecoration(
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
enabled: true,
prefixIcon: IconButton(
icon: Icon(CwtchIcons.send_invite, size: 24, color: Provider.of<Settings>(context).theme.mainTextColor()),
tooltip: AppLocalizations.of(context)!.sendInvite,
enableFeedback: true,
splashColor: Provider.of<Settings>(context).theme.defaultButtonActiveColor(),
hoverColor: Provider.of<Settings>(context).theme.defaultButtonActiveColor(),
onPressed: () => _modalSendInvitation(context)),
suffixIcon: IconButton(
icon: Icon(CwtchIcons.send_24px, size: 24, color: Provider.of<Settings>(context).theme.mainTextColor()),
tooltip: AppLocalizations.of(context)!.sendMessage,
onPressed: _sendMessage,
),
)))),
),
],
),
);
var children;
if (Provider.of<AppState>(context).selectedConversation != null && Provider.of<AppState>(context).selectedIndex != null) {
var quoted = FutureBuilder(
future: Provider.of<FlwtchState>(context)
.cwtch
.GetMessage(Provider.of<AppState>(context).selectedProfile!, Provider.of<AppState>(context).selectedConversation!, Provider.of<AppState>(context).selectedIndex!),
builder: (context, snapshot) {
if (snapshot.hasData) {
try {
var messageWrapper = jsonDecode(snapshot.data! as String);
dynamic message = jsonDecode(messageWrapper['Message']);
return Container(
margin: EdgeInsets.all(5),
padding: EdgeInsets.all(5),
color: messageWrapper['PeerID'] != Provider.of<AppState>(context).selectedProfile
? Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor()
: Provider.of<Settings>(context).theme.messageFromMeBackgroundColor(),
child: Text(message["d"]));
} catch (e) {
return MalformedBubble();
}
} else {
return Text("");
}
},
);
children = [quoted, composeBox];
} else {
children = [composeBox];
}
return Column(mainAxisSize: MainAxisSize.min, children: children);
}
// Send the message if enter is pressed without the shift key...
void handleKeyPress(event) {
var data = event.data as RawKeyEventData;
if (data.logicalKey == LogicalKeyboardKey.enter && !event.isShiftPressed) {
final messageWithoutNewLine = ctrlrCompose.value.text.trimRight();
ctrlrCompose.value = TextEditingValue(
text: messageWithoutNewLine
);
_sendMessage();
}
var data = event.data as RawKeyEventData;
if (data.logicalKey == LogicalKeyboardKey.enter && !event.isShiftPressed) {
final messageWithoutNewLine = ctrlrCompose.value.text.trimRight();
ctrlrCompose.value = TextEditingValue(text: messageWithoutNewLine);
_sendMessage();
}
}
void placeHolder() => {};

View File

@ -98,6 +98,7 @@ class _ContactRowState extends State<ContactRow> {
Provider.of<ProfileInfoState>(context, listen: false).contactList.getContact(contact.onion)!.unreadMessages = 0;
// triggers update in Double/TripleColumnView
Provider.of<AppState>(context, listen: false).selectedConversation = contact.onion;
Provider.of<AppState>(context, listen: false).selectedIndex = null;
// if in singlepane mode, push to the stack
var isLandscape = Provider.of<AppState>(context, listen: false).isLandscape(context);
if (Provider.of<Settings>(context, listen: false).uiColumns(isLandscape).length == 1) _pushMessageView(contact.onion);

View File

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:cwtch/widgets/quotedmessage.dart';
import 'package:flutter/material.dart';
import 'package:cwtch/widgets/profileimage.dart';
import 'package:provider/provider.dart';
@ -33,14 +34,18 @@ class _MessageRowState extends State<MessageRow> {
Widget wdgBubble =
Flexible(flex: 3, fit: FlexFit.loose, child: Provider.of<MessageState>(context).loaded == true ? widgetForOverlay(Provider.of<MessageState>(context).overlay) : MessageLoadingBubble());
Widget wdgIcons = Icon(Icons.delete_forever_outlined, color: Provider.of<Settings>(context).theme.dropShadowColor());
Widget wdgIcons = IconButton(
onPressed: () {
Provider.of<AppState>(context, listen: false).selectedIndex = Provider.of<MessageState>(context).messageIndex;
},
icon: Icon(Icons.reply, color: Provider.of<Settings>(context).theme.dropShadowColor()));
Widget wdgSpacer = Expanded(child: SizedBox(width: 60, height: 10));
var widgetRow = <Widget>[];
if (fromMe) {
widgetRow = <Widget>[
wdgSpacer,
//wdgIcons,
wdgIcons,
wdgBubble,
];
} else {
@ -60,7 +65,7 @@ class _MessageRowState extends State<MessageRow> {
widgetRow = <Widget>[
wdgPortrait,
wdgBubble,
//wdgIcons,
wdgIcons,
wdgSpacer,
];
}
@ -75,6 +80,8 @@ class _MessageRowState extends State<MessageRow> {
case 100:
case 101:
return InvitationBubble();
case 10:
return QuotedMessageBubble();
}
return MalformedBubble();
}

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:cwtch/main.dart';
import 'package:cwtch/widgets/malformedbubble.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -7,6 +10,22 @@ import 'package:intl/intl.dart';
import '../settings.dart';
import 'messagebubbledecorations.dart';
class LocallyIndexedMessage {
final dynamic message;
final int index;
LocallyIndexedMessage(this.message, this.index);
LocallyIndexedMessage.fromJson(Map<String, dynamic> json)
: message = json['Message'],
index = json['LocalIndex'];
Map<String, dynamic> toJson() => {
'Message': message,
'LocalIndex': index,
};
}
class QuotedMessageBubble extends StatefulWidget {
@override
QuotedMessageBubbleState createState() => QuotedMessageBubbleState();
@ -22,68 +41,110 @@ class QuotedMessageBubbleState extends State<QuotedMessageBubble> {
var borderRadiousEh = 15.0;
var myKey = Provider.of<MessageState>(context).profileOnion + "::" + Provider.of<MessageState>(context).contactHandle + "::" + Provider.of<MessageState>(context).messageIndex.toString();
if (Provider.of<MessageState>(context).timestamp != null) {
// user-configurable timestamps prolly ideal? #todo
DateTime messageDate = Provider.of<MessageState>(context).timestamp;
prettyDate = DateFormat.yMd().add_jm().format(messageDate.toLocal());
}
try {
dynamic message = jsonDecode(Provider.of<MessageState>(context).message);
// If the sender is not us, then we want to give them a nickname...
var senderDisplayStr = "";
if (!fromMe && Provider.of<MessageState>(context).senderOnion != null) {
ContactInfoState? contact = Provider.of<ProfileInfoState>(context).contactList.getContact(Provider.of<MessageState>(context).senderOnion);
if (contact != null) {
senderDisplayStr = contact.nickname;
} else {
senderDisplayStr = Provider.of<MessageState>(context).senderOnion;
var quotedMessagePotentials =
Provider.of<FlwtchState>(context).cwtch.GetMessageByContentHash(Provider.of<MessageState>(context).profileOnion, Provider.of<MessageState>(context).contactHandle, message["quotedHash"]);
int messageIndex = Provider.of<MessageState>(context).messageIndex;
var quotedMessage = quotedMessagePotentials.then((matchingMessages) {
// reverse order the messages from newest to oldest and return the
// first matching message where it's index is less than the index of this
// message
try {
var list = (jsonDecode(matchingMessages) as List<dynamic>).map((data) => LocallyIndexedMessage.fromJson(data)).toList();
LocallyIndexedMessage candidate = list.reversed.firstWhere((element) => messageIndex < element.index, orElse: () {
return list.firstWhere((element) => messageIndex > element.index);
});
return candidate;
} catch (e) {
// Malformed Message will be returned...
}
});
if (Provider.of<MessageState>(context).timestamp != null) {
// user-configurable timestamps prolly ideal? #todo
DateTime messageDate = Provider.of<MessageState>(context).timestamp;
prettyDate = DateFormat.yMd().add_jm().format(messageDate.toLocal());
}
}
var wdgSender = SelectableText(senderDisplayStr,
style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor() : Provider.of<Settings>(context).theme.messageFromOtherTextColor()));
var wdgMessage = SelectableText(
(Provider.of<MessageState>(context).message ?? "") + '\u202F',
key: Key(myKey),
focusNode: _focus,
style: TextStyle(
color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor() : Provider.of<Settings>(context).theme.messageFromOtherTextColor(),
),
textAlign: TextAlign.left,
textWidthBasis: TextWidthBasis.longestLine,
);
// If the sender is not us, then we want to give them a nickname...
var senderDisplayStr = "";
if (!fromMe && Provider.of<MessageState>(context).senderOnion != null) {
ContactInfoState? contact = Provider.of<ProfileInfoState>(context).contactList.getContact(Provider.of<MessageState>(context).senderOnion);
if (contact != null) {
senderDisplayStr = contact.nickname;
} else {
senderDisplayStr = Provider.of<MessageState>(context).senderOnion;
}
}
var wdgSender = SelectableText(senderDisplayStr,
style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor() : Provider.of<Settings>(context).theme.messageFromOtherTextColor()));
var wdgDecorations = MessageBubbleDecoration(ackd: Provider.of<MessageState>(context).ackd, errored: Provider.of<MessageState>(context).error, fromMe: fromMe, prettyDate: prettyDate);
var wdgMessage = SelectableText(
(message["body"] ?? "") + '\u202F',
key: Key(myKey),
focusNode: _focus,
style: TextStyle(
color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor() : Provider.of<Settings>(context).theme.messageFromOtherTextColor(),
),
textAlign: TextAlign.left,
textWidthBasis: TextWidthBasis.longestLine,
);
var error = Provider.of<MessageState>(context).error;
var wdgQuote = FutureBuilder(
future: quotedMessage,
builder: (context, snapshot) {
if (snapshot.hasData) {
var lim = (snapshot.data! as LocallyIndexedMessage);
var limmessage = lim.message;
// Swap the background color for quoted tweets..
return Container(
margin: EdgeInsets.all(5),
padding: EdgeInsets.all(5),
color: fromMe ? Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor() : Provider.of<Settings>(context).theme.messageFromMeBackgroundColor(),
child: Text(jsonDecode(limmessage)["d"]));
} else {
// This should be almost instantly resolved, any failure likely means an issue in decoding...
return MalformedBubble();
}
},
);
return LayoutBuilder(builder: (context, constraints) {
//print(constraints.toString()+", "+constraints.maxWidth.toString());
return RepaintBoundary(
child: Container(
child: Container(
decoration: BoxDecoration(
color: error
? malformedColor
: (fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor() : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor()),
border: Border.all(
color: error
? malformedColor
: (fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor() : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor()),
width: 1),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(borderRadiousEh),
topRight: Radius.circular(borderRadiousEh),
bottomLeft: fromMe ? Radius.circular(borderRadiousEh) : Radius.zero,
bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadiousEh),
var wdgDecorations = MessageBubbleDecoration(ackd: Provider.of<MessageState>(context).ackd, errored: Provider.of<MessageState>(context).error, fromMe: fromMe, prettyDate: prettyDate);
var error = Provider.of<MessageState>(context).error;
return LayoutBuilder(builder: (context, constraints) {
return RepaintBoundary(
child: Container(
child: Container(
decoration: BoxDecoration(
color: error
? malformedColor
: (fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor() : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor()),
border: Border.all(
color: error
? malformedColor
: (fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor() : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor()),
width: 1),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(borderRadiousEh),
topRight: Radius.circular(borderRadiousEh),
bottomLeft: fromMe ? Radius.circular(borderRadiousEh) : Radius.zero,
bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadiousEh),
),
),
),
child: Padding(
padding: EdgeInsets.all(9.0),
child: Column(
crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: fromMe ? [wdgMessage, wdgDecorations] : [wdgSender, wdgMessage, wdgDecorations])))));
});
child: Padding(
padding: EdgeInsets.all(9.0),
child: Column(
crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: fromMe ? [wdgQuote, wdgMessage, wdgDecorations] : [wdgSender, wdgQuote, wdgMessage, wdgDecorations])))));
});
} catch (e) {
return MalformedBubble();
}
}
}

View File

@ -57,6 +57,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0"
crypto:
dependency: "direct main"
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
cupertino_icons:
dependency: "direct main"
description:

View File

@ -35,6 +35,7 @@ dependencies:
ffi: ^1.0.0
path_provider: ^2.0.0
desktop_notifications: 0.5.0
crypto: 3.0.1
glob: any
flutter_test: