feat: Send image / video / file dialog
This commit is contained in:
parent
3e44b0e504
commit
80114dff80
|
@ -2,12 +2,14 @@
|
||||||
### Features
|
### Features
|
||||||
- Added translations: Armenian, Turkish, Chinese (Simplified)
|
- Added translations: Armenian, Turkish, Chinese (Simplified)
|
||||||
- Url-ify matrix identifiers
|
- Url-ify matrix identifiers
|
||||||
|
- Use server-side generated thumbnails in cleartext rooms
|
||||||
|
- Add option to send images in their original resolution
|
||||||
|
- Add additional confirmation for sending files & share intents
|
||||||
### Changes
|
### Changes
|
||||||
- Tapping links, pills, etc. now does stuff
|
- Tapping links, pills, etc. now does stuff
|
||||||
### Fixes:
|
### Fixes:
|
||||||
- Various html rendering and url-ifying fixes
|
- Various html rendering and url-ifying fixes
|
||||||
- Added support for blurhashes
|
- Added support for blurhashes
|
||||||
- Use server-side generated thumbnails in cleartext rooms
|
|
||||||
- Image viewer now eventually displays the original image, not only the thumbnail
|
- Image viewer now eventually displays the original image, not only the thumbnail
|
||||||
|
|
||||||
# Version 0.17.0 - 2020-08-31
|
# Version 0.17.0 - 2020-08-31
|
||||||
|
|
134
lib/components/dialogs/send_file_dialog.dart
Normal file
134
lib/components/dialogs/send_file_dialog.dart
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:famedlysdk/famedlysdk.dart';
|
||||||
|
import 'package:native_imaging/native_imaging.dart' as native;
|
||||||
|
|
||||||
|
import '../../utils/matrix_file_extension.dart';
|
||||||
|
import '../../utils/room_send_file_extension.dart';
|
||||||
|
import '../../components/dialogs/simple_dialogs.dart';
|
||||||
|
import '../../l10n/l10n.dart';
|
||||||
|
|
||||||
|
class SendFileDialog extends StatefulWidget {
|
||||||
|
final Room room;
|
||||||
|
final MatrixFile file;
|
||||||
|
|
||||||
|
const SendFileDialog({this.room, this.file, Key key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_SendFileDialogState createState() => _SendFileDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SendFileDialogState extends State<SendFileDialog> {
|
||||||
|
bool origImage = false;
|
||||||
|
|
||||||
|
Future<void> _send() async {
|
||||||
|
var file = widget.file;
|
||||||
|
if (file is MatrixImageFile && !origImage) {
|
||||||
|
final imgFile = file as MatrixImageFile;
|
||||||
|
// resize to max 1600 x 1600
|
||||||
|
try {
|
||||||
|
await native.init();
|
||||||
|
var nativeImg = native.Image();
|
||||||
|
try {
|
||||||
|
await nativeImg.loadEncoded(imgFile.bytes);
|
||||||
|
imgFile.width = nativeImg.width();
|
||||||
|
imgFile.height = nativeImg.height();
|
||||||
|
} on UnsupportedError {
|
||||||
|
final dartCodec = await instantiateImageCodec(imgFile.bytes);
|
||||||
|
final dartFrame = await dartCodec.getNextFrame();
|
||||||
|
imgFile.width = dartFrame.image.width;
|
||||||
|
imgFile.height = dartFrame.image.height;
|
||||||
|
final rgbaData = await dartFrame.image.toByteData();
|
||||||
|
final rgba = Uint8List.view(
|
||||||
|
rgbaData.buffer, rgbaData.offsetInBytes, rgbaData.lengthInBytes);
|
||||||
|
dartFrame.image.dispose();
|
||||||
|
dartCodec.dispose();
|
||||||
|
nativeImg.loadRGBA(imgFile.width, imgFile.height, rgba);
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = 1600;
|
||||||
|
if (imgFile.width > max || imgFile.height > max) {
|
||||||
|
var w = max, h = max;
|
||||||
|
if (imgFile.width > imgFile.height) {
|
||||||
|
h = max * imgFile.height ~/ imgFile.width;
|
||||||
|
} else {
|
||||||
|
w = max * imgFile.width ~/ imgFile.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
final scaledImg = nativeImg.resample(w, h, native.Transform.lanczos);
|
||||||
|
nativeImg.free();
|
||||||
|
nativeImg = scaledImg;
|
||||||
|
}
|
||||||
|
final jpegBytes = await nativeImg.toJpeg(75);
|
||||||
|
file = MatrixImageFile(
|
||||||
|
bytes: jpegBytes,
|
||||||
|
name: 'scaled_' + imgFile.name.split('.').first + '.jpg');
|
||||||
|
nativeImg.free();
|
||||||
|
} catch (e) {
|
||||||
|
// couldn't resize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await widget.room.sendFileEventWithThumbnail(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var sendStr = L10n.of(context).sendFile;
|
||||||
|
if (widget.file is MatrixImageFile) {
|
||||||
|
sendStr = L10n.of(context).sendImage;
|
||||||
|
} else if (widget.file is MatrixAudioFile) {
|
||||||
|
sendStr = L10n.of(context).sendAudio;
|
||||||
|
} else if (widget.file is MatrixVideoFile) {
|
||||||
|
sendStr = L10n.of(context).sendVideo;
|
||||||
|
}
|
||||||
|
Widget contentWidget;
|
||||||
|
if (widget.file is MatrixImageFile) {
|
||||||
|
contentWidget = Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||||
|
Flexible(
|
||||||
|
child: Image.memory(
|
||||||
|
widget.file.bytes,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(widget.file.name),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Checkbox(
|
||||||
|
value: origImage,
|
||||||
|
onChanged: (v) => setState(() => origImage = v),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(() => origImage = !origImage),
|
||||||
|
child: Text(L10n.of(context).sendOriginal +
|
||||||
|
' (${widget.file.sizeString})'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
contentWidget = Text('${widget.file.name} (${widget.file.sizeString})');
|
||||||
|
}
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(sendStr),
|
||||||
|
content: contentWidget,
|
||||||
|
actions: <Widget>[
|
||||||
|
FlatButton(
|
||||||
|
child: Text(L10n.of(context).cancel),
|
||||||
|
onPressed: () {
|
||||||
|
// just close the dialog
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FlatButton(
|
||||||
|
child: Text(L10n.of(context).send),
|
||||||
|
onPressed: () async {
|
||||||
|
await SimpleDialogs(context).tryRequestWithLoadingDialog(_send());
|
||||||
|
await Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import '../theme_switcher.dart';
|
||||||
import '../avatar.dart';
|
import '../avatar.dart';
|
||||||
import '../dialogs/simple_dialogs.dart';
|
import '../dialogs/simple_dialogs.dart';
|
||||||
import '../matrix.dart';
|
import '../matrix.dart';
|
||||||
|
import '../dialogs/send_file_dialog.dart';
|
||||||
|
|
||||||
class ChatListItem extends StatelessWidget {
|
class ChatListItem extends StatelessWidget {
|
||||||
final Room room;
|
final Room room;
|
||||||
|
@ -73,11 +74,12 @@ class ChatListItem extends StatelessWidget {
|
||||||
if (Matrix.of(context).shareContent != null) {
|
if (Matrix.of(context).shareContent != null) {
|
||||||
if (Matrix.of(context).shareContent['msgtype'] ==
|
if (Matrix.of(context).shareContent['msgtype'] ==
|
||||||
'chat.fluffy.shared_file') {
|
'chat.fluffy.shared_file') {
|
||||||
await SimpleDialogs(context).tryRequestWithErrorToast(
|
await showDialog(
|
||||||
room.sendFileEvent(
|
context: context,
|
||||||
Matrix.of(context).shareContent['file'],
|
builder: (context) => SendFileDialog(
|
||||||
),
|
file: Matrix.of(context).shareContent['file'],
|
||||||
);
|
room: room,
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
unawaited(room.sendEvent(Matrix.of(context).shareContent));
|
unawaited(room.sendEvent(Matrix.of(context).shareContent));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"@@last_modified": "2020-08-16T12:43:17.825046",
|
"@@last_modified": "2020-09-04T14:58:35.809079",
|
||||||
"About": "About",
|
"About": "About",
|
||||||
"@About": {
|
"@About": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
@ -1184,6 +1184,11 @@
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
|
"Send audio": "Send audio",
|
||||||
|
"@Send audio": {
|
||||||
|
"type": "text",
|
||||||
|
"placeholders": {}
|
||||||
|
},
|
||||||
"Send file": "Send file",
|
"Send file": "Send file",
|
||||||
"@Send file": {
|
"@Send file": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
@ -1194,6 +1199,16 @@
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
|
"Send original": "Send original",
|
||||||
|
"@Send original": {
|
||||||
|
"type": "text",
|
||||||
|
"placeholders": {}
|
||||||
|
},
|
||||||
|
"Send video": "Send video",
|
||||||
|
"@Send video": {
|
||||||
|
"type": "text",
|
||||||
|
"placeholders": {}
|
||||||
|
},
|
||||||
"sentAFile": "{username} sent a file",
|
"sentAFile": "{username} sent a file",
|
||||||
"@sentAFile": {
|
"@sentAFile": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|
|
@ -735,10 +735,16 @@ class L10n extends MatrixLocalizations {
|
||||||
|
|
||||||
String get sendAMessage => Intl.message("Send a message");
|
String get sendAMessage => Intl.message("Send a message");
|
||||||
|
|
||||||
|
String get sendAudio => Intl.message('Send audio');
|
||||||
|
|
||||||
String get sendFile => Intl.message('Send file');
|
String get sendFile => Intl.message('Send file');
|
||||||
|
|
||||||
String get sendImage => Intl.message('Send image');
|
String get sendImage => Intl.message('Send image');
|
||||||
|
|
||||||
|
String get sendOriginal => Intl.message('Send original');
|
||||||
|
|
||||||
|
String get sendVideo => Intl.message('Send video');
|
||||||
|
|
||||||
String sentAFile(String username) => Intl.message(
|
String sentAFile(String username) => Intl.message(
|
||||||
"$username sent a file",
|
"$username sent a file",
|
||||||
name: "sentAFile",
|
name: "sentAFile",
|
||||||
|
|
|
@ -394,8 +394,11 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||||
"Send": MessageLookupByLibrary.simpleMessage("Send"),
|
"Send": MessageLookupByLibrary.simpleMessage("Send"),
|
||||||
"Send a message":
|
"Send a message":
|
||||||
MessageLookupByLibrary.simpleMessage("Send a message"),
|
MessageLookupByLibrary.simpleMessage("Send a message"),
|
||||||
|
"Send audio": MessageLookupByLibrary.simpleMessage("Send audio"),
|
||||||
"Send file": MessageLookupByLibrary.simpleMessage("Send file"),
|
"Send file": MessageLookupByLibrary.simpleMessage("Send file"),
|
||||||
"Send image": MessageLookupByLibrary.simpleMessage("Send image"),
|
"Send image": MessageLookupByLibrary.simpleMessage("Send image"),
|
||||||
|
"Send original": MessageLookupByLibrary.simpleMessage("Send original"),
|
||||||
|
"Send video": MessageLookupByLibrary.simpleMessage("Send video"),
|
||||||
"Set a profile picture":
|
"Set a profile picture":
|
||||||
MessageLookupByLibrary.simpleMessage("Set a profile picture"),
|
MessageLookupByLibrary.simpleMessage("Set a profile picture"),
|
||||||
"Set group description":
|
"Set group description":
|
||||||
|
|
|
@ -31,4 +31,34 @@ extension MatrixFileExtension on MatrixFile {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MatrixFile get detectFileType {
|
||||||
|
if (msgType == MessageTypes.Image) {
|
||||||
|
return MatrixImageFile(bytes: bytes, name: name);
|
||||||
|
}
|
||||||
|
if (msgType == MessageTypes.Video) {
|
||||||
|
return MatrixVideoFile(bytes: bytes, name: name);
|
||||||
|
}
|
||||||
|
if (msgType == MessageTypes.Audio) {
|
||||||
|
return MatrixAudioFile(bytes: bytes, name: name);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get sizeString {
|
||||||
|
var size = this.size.toDouble();
|
||||||
|
if (size < 1000000) {
|
||||||
|
size = size / 1000;
|
||||||
|
size = (size * 10).round() / 10;
|
||||||
|
return '${size.toString()} KB';
|
||||||
|
} else if (size < 1000000000) {
|
||||||
|
size = size / 1000000;
|
||||||
|
size = (size * 10).round() / 10;
|
||||||
|
return '${size.toString()} MB';
|
||||||
|
} else {
|
||||||
|
size = size / 1000000000;
|
||||||
|
size = (size * 10).round() / 10;
|
||||||
|
return '${size.toString()} GB';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,11 +23,13 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:memoryfilepicker/memoryfilepicker.dart';
|
import 'package:memoryfilepicker/memoryfilepicker.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:file_picker_platform_interface/file_picker_platform_interface.dart';
|
||||||
|
|
||||||
import 'chat_details.dart';
|
import 'chat_details.dart';
|
||||||
import 'chat_list.dart';
|
import 'chat_list.dart';
|
||||||
import '../components/input_bar.dart';
|
import '../components/input_bar.dart';
|
||||||
import '../utils/room_send_file_extension.dart';
|
import '../components/dialogs/send_file_dialog.dart';
|
||||||
|
import '../utils/matrix_file_extension.dart';
|
||||||
|
|
||||||
class ChatView extends StatelessWidget {
|
class ChatView extends StatelessWidget {
|
||||||
final String id;
|
final String id;
|
||||||
|
@ -191,39 +193,36 @@ class _ChatState extends State<_Chat> {
|
||||||
void sendFileAction(BuildContext context) async {
|
void sendFileAction(BuildContext context) async {
|
||||||
var file = await MemoryFilePicker.getFile();
|
var file = await MemoryFilePicker.getFile();
|
||||||
if (file == null) return;
|
if (file == null) return;
|
||||||
await SimpleDialogs(context).tryRequestWithLoadingDialog(
|
await showDialog(
|
||||||
room.sendFileEventWithThumbnail(
|
context: context,
|
||||||
MatrixFile(bytes: file.bytes, name: file.path),
|
builder: (context) => SendFileDialog(
|
||||||
),
|
file:
|
||||||
);
|
MatrixFile(bytes: file.bytes, name: file.path).detectFileType,
|
||||||
|
room: room,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendImageAction(BuildContext context) async {
|
void sendImageAction(BuildContext context) async {
|
||||||
var file = await MemoryFilePicker.getImage(
|
var file = await MemoryFilePicker.getFile(type: FileType.image);
|
||||||
source: ImageSource.gallery,
|
|
||||||
imageQuality: 50,
|
|
||||||
maxWidth: 1600,
|
|
||||||
maxHeight: 1600);
|
|
||||||
if (file == null) return;
|
if (file == null) return;
|
||||||
await SimpleDialogs(context).tryRequestWithLoadingDialog(
|
final bytes = await file.bytes;
|
||||||
room.sendFileEventWithThumbnail(
|
await showDialog(
|
||||||
MatrixImageFile(bytes: await file.bytes, name: file.path),
|
context: context,
|
||||||
),
|
builder: (context) => SendFileDialog(
|
||||||
);
|
file: MatrixImageFile(bytes: bytes, name: file.path),
|
||||||
|
room: room,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void openCameraAction(BuildContext context) async {
|
void openCameraAction(BuildContext context) async {
|
||||||
var file = await MemoryFilePicker.getImage(
|
var file = await MemoryFilePicker.getImage(source: ImageSource.camera);
|
||||||
source: ImageSource.camera,
|
|
||||||
imageQuality: 50,
|
|
||||||
maxWidth: 1600,
|
|
||||||
maxHeight: 1600);
|
|
||||||
if (file == null) return;
|
if (file == null) return;
|
||||||
await SimpleDialogs(context).tryRequestWithLoadingDialog(
|
await showDialog(
|
||||||
room.sendFileEventWithThumbnail(
|
context: context,
|
||||||
MatrixImageFile(bytes: file.bytes, name: file.path),
|
builder: (context) => SendFileDialog(
|
||||||
),
|
file: MatrixImageFile(bytes: file.bytes, name: file.path),
|
||||||
);
|
room: room,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void voiceMessageAction(BuildContext context) async {
|
void voiceMessageAction(BuildContext context) async {
|
||||||
|
@ -235,12 +234,13 @@ class _ChatState extends State<_Chat> {
|
||||||
));
|
));
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
final audioFile = File(result);
|
final audioFile = File(result);
|
||||||
await SimpleDialogs(context).tryRequestWithLoadingDialog(
|
await showDialog(
|
||||||
room.sendFileEvent(
|
context: context,
|
||||||
MatrixAudioFile(
|
builder: (context) => SendFileDialog(
|
||||||
|
file: MatrixAudioFile(
|
||||||
bytes: audioFile.readAsBytesSync(), name: audioFile.path),
|
bytes: audioFile.readAsBytesSync(), name: audioFile.path),
|
||||||
),
|
room: room,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getSelectedEventString(BuildContext context) {
|
String _getSelectedEventString(BuildContext context) {
|
||||||
|
|
|
@ -17,6 +17,7 @@ import '../components/matrix.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../utils/app_route.dart';
|
import '../utils/app_route.dart';
|
||||||
import '../utils/url_launcher.dart';
|
import '../utils/url_launcher.dart';
|
||||||
|
import '../utils/matrix_file_extension.dart';
|
||||||
import 'archive.dart';
|
import 'archive.dart';
|
||||||
import 'homeserver_picker.dart';
|
import 'homeserver_picker.dart';
|
||||||
import 'new_group.dart';
|
import 'new_group.dart';
|
||||||
|
@ -119,7 +120,7 @@ class _ChatListState extends State<ChatList> {
|
||||||
});
|
});
|
||||||
setState(() => null);
|
setState(() => null);
|
||||||
});
|
});
|
||||||
_initReceiveSharingINtent();
|
_initReceiveSharingIntent();
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,7 +140,7 @@ class _ChatListState extends State<ChatList> {
|
||||||
'file': MatrixFile(
|
'file': MatrixFile(
|
||||||
bytes: file.readAsBytesSync(),
|
bytes: file.readAsBytesSync(),
|
||||||
name: file.path,
|
name: file.path,
|
||||||
),
|
).detectFileType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,7 +159,7 @@ class _ChatListState extends State<ChatList> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initReceiveSharingINtent() {
|
void _initReceiveSharingIntent() {
|
||||||
if (kIsWeb) return;
|
if (kIsWeb) return;
|
||||||
|
|
||||||
// For sharing images coming from outside the app while the app is in the memory
|
// For sharing images coming from outside the app while the app is in the memory
|
||||||
|
|
11
pubspec.lock
11
pubspec.lock
|
@ -248,6 +248,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.0"
|
||||||
|
flutter_cache_manager:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_cache_manager
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
flutter_keyboard_visibility:
|
flutter_keyboard_visibility:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -486,7 +493,7 @@ packages:
|
||||||
name: memoryfilepicker
|
name: memoryfilepicker
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.1"
|
version: "0.1.3"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1000,4 +1007,4 @@ packages:
|
||||||
version: "0.1.2"
|
version: "0.1.2"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.10.0-0.0.dev <2.10.0"
|
dart: ">=2.10.0-0.0.dev <2.10.0"
|
||||||
flutter: ">=1.18.0-6.0.pre <2.0.0"
|
flutter: ">=1.20.0 <2.0.0"
|
||||||
|
|
|
@ -31,7 +31,7 @@ dependencies:
|
||||||
|
|
||||||
localstorage: ^3.0.1+4
|
localstorage: ^3.0.1+4
|
||||||
bubble: ^1.1.9+1
|
bubble: ^1.1.9+1
|
||||||
memoryfilepicker: ^0.1.1
|
memoryfilepicker: ^0.1.3
|
||||||
url_launcher: ^5.4.1
|
url_launcher: ^5.4.1
|
||||||
url_launcher_web: ^0.1.0
|
url_launcher_web: ^0.1.0
|
||||||
flutter_advanced_networkimage:
|
flutter_advanced_networkimage:
|
||||||
|
|
Loading…
Reference in a new issue