feat: Drag&Drop to send multiple files on desktop and web

This commit is contained in:
Krille Fear 2021-11-28 11:43:36 +01:00
commit a134e06203
10 changed files with 246 additions and 188 deletions

View file

@ -6,6 +6,7 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:flutter_app_badger/flutter_app_badger.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -29,8 +30,8 @@ import '../../utils/account_bundles.dart';
import '../../utils/localized_exception_extension.dart';
import '../../utils/matrix_sdk_extensions.dart/filtered_timeline_extension.dart';
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
import '../new_private_chat/send_file_dialog.dart';
import '../new_private_chat/send_location_dialog.dart';
import 'send_file_dialog.dart';
import 'send_location_dialog.dart';
import 'sticker_picker_dialog.dart';
class Chat extends StatefulWidget {
@ -60,6 +61,28 @@ class ChatController extends State<Chat> {
Timer typingCoolDown;
Timer typingTimeout;
bool currentlyTyping = false;
bool dragging = false;
void onDragEntered(_) => setState(() => dragging = true);
void onDragExited(_) => setState(() => dragging = false);
void onDragDone(DropDoneDetails details) async {
setState(() => dragging = false);
for (final url in details.urls) {
final file = File.fromUri(url);
final bytes = await file.readAsBytes();
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
file: MatrixFile(
bytes: bytes,
name: file.path.split('/').last,
).detectFileType,
room: room,
),
);
}
}
List<Event> selectedEvents = [];

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
@ -168,202 +169,223 @@ class ChatView extends StatelessWidget {
)
: null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: Stack(
children: <Widget>[
if (Matrix.of(context).wallpaper != null)
Image.file(
Matrix.of(context).wallpaper,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
SafeArea(
child: Column(
children: <Widget>[
TombstoneDisplay(controller),
Expanded(
child: FutureBuilder<bool>(
future: controller.getTimeline(),
builder: (BuildContext context, snapshot) {
if (snapshot.hasError) {
SentryController.captureException(
snapshot.error,
StackTrace.current,
);
}
if (controller.timeline == null) {
return const Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2),
);
}
body: DropTarget(
onDragDone: controller.onDragDone,
onDragEntered: controller.onDragEntered,
onDragExited: controller.onDragExited,
child: Stack(
children: <Widget>[
if (Matrix.of(context).wallpaper != null)
Image.file(
Matrix.of(context).wallpaper,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
SafeArea(
child: Column(
children: <Widget>[
TombstoneDisplay(controller),
Expanded(
child: FutureBuilder<bool>(
future: controller.getTimeline(),
builder: (BuildContext context, snapshot) {
if (snapshot.hasError) {
SentryController.captureException(
snapshot.error,
StackTrace.current,
);
}
if (controller.timeline == null) {
return const Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2),
);
}
// create a map of eventId --> index to greatly improve performance of
// ListView's findChildIndexCallback
final thisEventsKeyMap = <String, int>{};
for (var i = 0;
i < controller.filteredEvents.length;
i++) {
thisEventsKeyMap[
controller.filteredEvents[i].eventId] = i;
}
return ListView.custom(
padding: EdgeInsets.only(
top: 16,
bottom: 4,
left: horizontalPadding,
right: horizontalPadding,
),
reverse: true,
controller: controller.scrollController,
keyboardDismissBehavior: PlatformInfos.isIOS
? ScrollViewKeyboardDismissBehavior.onDrag
: ScrollViewKeyboardDismissBehavior.manual,
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int i) {
return i == controller.filteredEvents.length + 1
? controller.timeline.isRequestingHistory
? const Center(
child: CircularProgressIndicator
.adaptive(strokeWidth: 2),
)
: controller.canLoadMore
? Center(
child: OutlinedButton(
style:
OutlinedButton.styleFrom(
backgroundColor: Theme.of(
context)
.scaffoldBackgroundColor,
// create a map of eventId --> index to greatly improve performance of
// ListView's findChildIndexCallback
final thisEventsKeyMap = <String, int>{};
for (var i = 0;
i < controller.filteredEvents.length;
i++) {
thisEventsKeyMap[
controller.filteredEvents[i].eventId] = i;
}
return ListView.custom(
padding: EdgeInsets.only(
top: 16,
bottom: 4,
left: horizontalPadding,
right: horizontalPadding,
),
reverse: true,
controller: controller.scrollController,
keyboardDismissBehavior: PlatformInfos.isIOS
? ScrollViewKeyboardDismissBehavior.onDrag
: ScrollViewKeyboardDismissBehavior.manual,
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int i) {
return i ==
controller.filteredEvents.length + 1
? controller.timeline.isRequestingHistory
? const Center(
child: CircularProgressIndicator
.adaptive(strokeWidth: 2),
)
: controller.canLoadMore
? Center(
child: OutlinedButton(
style: OutlinedButton
.styleFrom(
backgroundColor: Theme.of(
context)
.scaffoldBackgroundColor,
),
onPressed: controller
.requestHistory,
child: Text(L10n.of(context)
.loadMore),
),
onPressed:
controller.requestHistory,
child: Text(L10n.of(context)
.loadMore),
),
)
: Container()
: i == 0
? Column(
mainAxisSize: MainAxisSize.min,
children: [
SeenByRow(controller),
TypingIndicators(controller),
],
)
: AutoScrollTag(
key: ValueKey(controller
.filteredEvents[i - 1].eventId),
index: i - 1,
controller:
controller.scrollController,
child: Swipeable(
)
: Container()
: i == 0
? Column(
mainAxisSize: MainAxisSize.min,
children: [
SeenByRow(controller),
TypingIndicators(controller),
],
)
: AutoScrollTag(
key: ValueKey(controller
.filteredEvents[i - 1]
.eventId),
background: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.0),
child: Center(
child: Icon(
Icons.reply_outlined),
index: i - 1,
controller:
controller.scrollController,
child: Swipeable(
key: ValueKey(controller
.filteredEvents[i - 1]
.eventId),
background: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.0),
child: Center(
child: Icon(
Icons.reply_outlined),
),
),
),
direction:
SwipeDirection.endToStart,
onSwipe: (direction) =>
controller.replyAction(
replyTo: controller
.filteredEvents[
i - 1]),
child: Message(
controller
.filteredEvents[i - 1],
onInfoTab:
controller.showEventInfo,
onAvatarTab: (Event event) =>
showModalBottomSheet(
context: context,
builder: (c) =>
UserBottomSheet(
user: event.sender,
outerContext: context,
onMention: () => controller
.sendController
.text +=
'${event.sender.mention} ',
direction:
SwipeDirection.endToStart,
onSwipe: (direction) =>
controller.replyAction(
replyTo: controller
.filteredEvents[
i - 1]),
child: Message(
controller
.filteredEvents[i - 1],
onInfoTab: controller
.showEventInfo,
onAvatarTab: (Event event) =>
showModalBottomSheet(
context: context,
builder: (c) =>
UserBottomSheet(
user: event.sender,
outerContext:
context,
onMention: () => controller
.sendController
.text +=
'${event.sender.mention} ',
),
),
),
unfold: controller.unfold,
onSelect: controller
.onSelectMessage,
scrollToEventId: (String eventId) => controller
.scrollToEventId(eventId),
longPressSelect: controller
.selectedEvents.isEmpty,
selected: controller.selectedEvents.any((e) =>
e.eventId ==
controller
.filteredEvents[i - 1]
.eventId),
timeline: controller.timeline,
nextEvent: i <
controller
.filteredEvents
.length
? controller.filteredEvents[i]
: null),
),
);
},
childCount: controller.filteredEvents.length + 2,
findChildIndexCallback: (key) =>
controller.findChildIndexCallback(
key, thisEventsKeyMap),
unfold: controller.unfold,
onSelect: controller
.onSelectMessage,
scrollToEventId:
(String eventId) =>
controller.scrollToEventId(
eventId),
longPressSelect: controller
.selectedEvents.isEmpty,
selected: controller
.selectedEvents
.any((e) =>
e.eventId ==
controller
.filteredEvents[i - 1]
.eventId),
timeline: controller.timeline,
nextEvent: i < controller.filteredEvents.length ? controller.filteredEvents[i] : null),
),
);
},
childCount:
controller.filteredEvents.length + 2,
findChildIndexCallback: (key) =>
controller.findChildIndexCallback(
key, thisEventsKeyMap),
),
);
},
),
),
if (controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join)
Container(
margin: EdgeInsets.only(
bottom: bottomSheetPadding,
left: bottomSheetPadding,
right: bottomSheetPadding,
),
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5),
alignment: Alignment.center,
child: Material(
borderRadius: const BorderRadius.only(
bottomLeft:
Radius.circular(AppConfig.borderRadius),
bottomRight:
Radius.circular(AppConfig.borderRadius),
),
elevation: 6,
shadowColor: Theme.of(context)
.secondaryHeaderColor
.withAlpha(100),
clipBehavior: Clip.hardEdge,
color:
Theme.of(context).appBarTheme.backgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ConnectionStatusHeader(),
ReactionsPicker(controller),
ReplyDisplay(controller),
ChatInputRow(controller),
ChatEmojiPicker(controller),
],
),
);
},
),
),
if (controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join)
Container(
margin: EdgeInsets.only(
bottom: bottomSheetPadding,
left: bottomSheetPadding,
right: bottomSheetPadding,
),
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5),
alignment: Alignment.center,
child: Material(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(AppConfig.borderRadius),
bottomRight:
Radius.circular(AppConfig.borderRadius),
),
elevation: 6,
shadowColor: Theme.of(context)
.secondaryHeaderColor
.withAlpha(100),
clipBehavior: Clip.hardEdge,
color: Theme.of(context).appBarTheme.backgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ConnectionStatusHeader(),
ReactionsPicker(controller),
ReplyDisplay(controller),
ChatInputRow(controller),
ChatEmojiPicker(controller),
],
),
),
),
],
],
),
),
),
],
if (controller.dragging)
Container(
color: Theme.of(context)
.scaffoldBackgroundColor
.withOpacity(0.9),
alignment: Alignment.center,
child: const Icon(
Icons.upload_outlined,
size: 100,
),
),
],
),
),
),
),

View file

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
import '../../utils/resize_image.dart';
import '../../utils/room_send_file_extension.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;
bool _isSending = false;
Future<void> _send() async {
var file = widget.file;
if (file is MatrixImageFile && !origImage) {
file = await file.resizeImage(quality: 40, max: 1200);
}
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>[
TextButton(
onPressed: () {
// just close the dialog
Navigator.of(context, rootNavigator: false).pop();
},
child: Text(L10n.of(context).cancel),
),
TextButton(
onPressed: _isSending
? null
: () async {
setState(() {
_isSending = true;
});
await showFutureLoadingDialog(
context: context, future: () => _send());
Navigator.of(context, rootNavigator: false).pop();
},
child: Text(L10n.of(context).send),
),
],
);
}
}

View file

@ -0,0 +1,146 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:geolocator/geolocator.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat/events/map_bubble.dart';
import 'package:fluffychat/utils/platform_infos.dart';
class SendLocationDialog extends StatefulWidget {
final Room room;
const SendLocationDialog({
this.room,
Key key,
}) : super(key: key);
@override
_SendLocationDialogState createState() => _SendLocationDialogState();
}
class _SendLocationDialogState extends State<SendLocationDialog> {
bool disabled = false;
bool denied = false;
bool isSending = false;
Position position;
Error error;
@override
void initState() {
super.initState();
requestLocation();
}
Future<void> requestLocation() async {
if (!(await Geolocator.isLocationServiceEnabled())) {
setState(() => disabled = true);
return;
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
setState(() => denied = true);
return;
}
}
if (permission == LocationPermission.deniedForever) {
setState(() => denied = true);
return;
}
try {
Position _position;
try {
_position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.best,
timeLimit: const Duration(seconds: 30),
);
} on TimeoutException {
_position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 30),
);
}
setState(() => position = _position);
} catch (e) {
setState(() => error = e);
}
}
void sendAction() async {
setState(() => isSending = true);
final body =
'https://www.openstreetmap.org/?mlat=${position.latitude}&mlon=${position.longitude}#map=16/${position.latitude}/${position.longitude}';
final uri =
'geo:${position.latitude},${position.longitude};u=${position.accuracy}';
await showFutureLoadingDialog(
context: context,
future: () => widget.room.sendLocation(body, uri),
);
Navigator.of(context, rootNavigator: false).pop();
}
@override
Widget build(BuildContext context) {
Widget contentWidget;
if (position != null) {
contentWidget = MapBubble(
latitude: position.latitude,
longitude: position.longitude,
);
} else if (disabled) {
contentWidget = Text(L10n.of(context).locationDisabledNotice);
} else if (denied) {
contentWidget = Text(L10n.of(context).locationPermissionDeniedNotice);
} else if (error != null) {
contentWidget =
Text(L10n.of(context).errorObtainingLocation(error.toString()));
} else {
contentWidget = Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CupertinoActivityIndicator(),
const SizedBox(width: 12),
Text(L10n.of(context).obtainingLocation),
],
);
}
if (PlatformInfos.isCupertinoStyle) {
return CupertinoAlertDialog(
title: Text(L10n.of(context).shareLocation),
content: contentWidget,
actions: [
CupertinoDialogAction(
onPressed: Navigator.of(context, rootNavigator: false).pop,
child: Text(L10n.of(context).cancel),
),
CupertinoDialogAction(
onPressed: isSending ? null : sendAction,
child: Text(L10n.of(context).send),
),
],
);
}
return AlertDialog(
title: Text(L10n.of(context).shareLocation),
content: contentWidget,
actions: [
TextButton(
onPressed: Navigator.of(context, rootNavigator: false).pop,
child: Text(L10n.of(context).cancel),
),
if (position != null)
TextButton(
onPressed: isSending ? null : sendAction,
child: Text(L10n.of(context).send),
),
],
);
}
}