refactor: Pages folder structure
This commit is contained in:
parent
5fe495db94
commit
1abb7310f3
88 changed files with 188 additions and 250 deletions
795
lib/pages/chat/chat.dart
Normal file
795
lib/pages/chat/chat.dart
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.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';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pages/chat/chat_view.dart';
|
||||
import 'package:fluffychat/pages/chat/recording_dialog.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
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 'sticker_picker_dialog.dart';
|
||||
|
||||
class Chat extends StatefulWidget {
|
||||
final Widget sideView;
|
||||
|
||||
const Chat({Key key, this.sideView}) : super(key: key);
|
||||
|
||||
@override
|
||||
ChatController createState() => ChatController();
|
||||
}
|
||||
|
||||
class ChatController extends State<Chat> {
|
||||
Room room;
|
||||
|
||||
Client sendingClient;
|
||||
|
||||
Timeline timeline;
|
||||
|
||||
MatrixState matrix;
|
||||
|
||||
String get roomId => context.vRouter.pathParameters['roomid'];
|
||||
|
||||
final AutoScrollController scrollController = AutoScrollController();
|
||||
|
||||
FocusNode inputFocus = FocusNode();
|
||||
|
||||
Timer typingCoolDown;
|
||||
Timer typingTimeout;
|
||||
bool currentlyTyping = false;
|
||||
|
||||
List<Event> selectedEvents = [];
|
||||
|
||||
List<Event> filteredEvents;
|
||||
|
||||
final Set<String> unfolded = {};
|
||||
|
||||
Event replyEvent;
|
||||
|
||||
Event editEvent;
|
||||
|
||||
bool showScrollDownButton = false;
|
||||
|
||||
bool get selectMode => selectedEvents.isNotEmpty;
|
||||
|
||||
final int _loadHistoryCount = 100;
|
||||
|
||||
String inputText = '';
|
||||
|
||||
String pendingText = '';
|
||||
|
||||
bool get canLoadMore =>
|
||||
timeline.events.isEmpty ||
|
||||
timeline.events.last.type != EventTypes.RoomCreate;
|
||||
|
||||
bool showEmojiPicker = false;
|
||||
|
||||
void startCallAction() async {
|
||||
final url =
|
||||
'${AppConfig.jitsiInstance}${Uri.encodeComponent(Matrix.of(context).client.generateUniqueTransactionId())}';
|
||||
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.sendEvent({
|
||||
'msgtype': Matrix.callNamespace,
|
||||
'body': url,
|
||||
}));
|
||||
if (success.error != null) return;
|
||||
await launch(url);
|
||||
}
|
||||
|
||||
void requestHistory() async {
|
||||
if (canLoadMore) {
|
||||
try {
|
||||
await timeline.requestHistory(historyCount: _loadHistoryCount);
|
||||
} catch (err) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
(err as Object).toLocalizedString(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _updateScrollController() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (scrollController.position.pixels ==
|
||||
scrollController.position.maxScrollExtent &&
|
||||
timeline.events.isNotEmpty &&
|
||||
timeline.events[timeline.events.length - 1].type !=
|
||||
EventTypes.RoomCreate) {
|
||||
requestHistory();
|
||||
}
|
||||
if (scrollController.position.pixels > 0 && showScrollDownButton == false) {
|
||||
setState(() => showScrollDownButton = true);
|
||||
} else if (scrollController.position.pixels == 0 &&
|
||||
showScrollDownButton == true) {
|
||||
setState(() => showScrollDownButton = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
scrollController.addListener(_updateScrollController);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void updateView() {
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() {
|
||||
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void unfold(String eventId) {
|
||||
var i = filteredEvents.indexWhere((e) => e.eventId == eventId);
|
||||
setState(() {
|
||||
while (i < filteredEvents.length - 1 && filteredEvents[i].isState) {
|
||||
unfolded.add(filteredEvents[i].eventId);
|
||||
i++;
|
||||
}
|
||||
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> getTimeline() async {
|
||||
if (timeline == null) {
|
||||
timeline = await room.getTimeline(onUpdate: updateView);
|
||||
if (timeline.events.isNotEmpty) {
|
||||
// ignore: unawaited_futures
|
||||
room.markUnread(false).catchError((err) {
|
||||
if (err is MatrixException && err.errcode == 'M_FORBIDDEN') {
|
||||
// ignore if the user is not in the room (still joining)
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// when the scroll controller is attached we want to scroll to an event id, if specified
|
||||
// and update the scroll controller...which will trigger a request history, if the
|
||||
// "load more" button is visible on the screen
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
||||
if (mounted) {
|
||||
final event = VRouter.of(context).queryParameters['event'];
|
||||
if (event != null) {
|
||||
scrollToEventId(event);
|
||||
}
|
||||
_updateScrollController();
|
||||
}
|
||||
});
|
||||
}
|
||||
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
|
||||
if (room.notificationCount != null &&
|
||||
room.notificationCount > 0 &&
|
||||
timeline != null &&
|
||||
timeline.events.isNotEmpty &&
|
||||
Matrix.of(context).webHasFocus) {
|
||||
// ignore: unawaited_futures
|
||||
timeline.setReadMarker();
|
||||
if (PlatformInfos.isIOS) {
|
||||
// Workaround for iOS not clearing notifications with fcm_shared_isolate
|
||||
if (!room.client.rooms.any((r) =>
|
||||
r.membership == Membership.invite ||
|
||||
(r.notificationCount != null && r.notificationCount > 0))) {
|
||||
// ignore: unawaited_futures
|
||||
FlutterLocalNotificationsPlugin().cancelAll();
|
||||
FlutterAppBadger.removeBadge();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timeline?.cancelSubscriptions();
|
||||
timeline = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
TextEditingController sendController = TextEditingController();
|
||||
|
||||
void setSendingClient(Client c) {
|
||||
// first cancle typing with the old sending client
|
||||
if (currentlyTyping) {
|
||||
// no need to have the setting typing to false be blocking
|
||||
typingCoolDown?.cancel();
|
||||
typingCoolDown = null;
|
||||
room.setTyping(false);
|
||||
currentlyTyping = false;
|
||||
}
|
||||
// then set the new sending client
|
||||
setState(() => sendingClient = c);
|
||||
}
|
||||
|
||||
void setActiveClient(Client c) => setState(() {
|
||||
Matrix.of(context).setActiveClient(c);
|
||||
});
|
||||
|
||||
Future<void> send() async {
|
||||
if (sendController.text.trim().isEmpty) return;
|
||||
var parseCommands = true;
|
||||
|
||||
final commandMatch = RegExp(r'^\/(\w+)').firstMatch(sendController.text);
|
||||
if (commandMatch != null &&
|
||||
!room.client.commands.keys.contains(commandMatch[1].toLowerCase())) {
|
||||
final l10n = L10n.of(context);
|
||||
final dialogResult = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
title: l10n.commandInvalid,
|
||||
message: l10n.commandMissing(commandMatch[0]),
|
||||
okLabel: l10n.sendAsText,
|
||||
cancelLabel: l10n.cancel,
|
||||
);
|
||||
if (dialogResult == null || dialogResult == OkCancelResult.cancel) return;
|
||||
parseCommands = false;
|
||||
}
|
||||
|
||||
// ignore: unawaited_futures
|
||||
room.sendTextEvent(sendController.text,
|
||||
inReplyTo: replyEvent,
|
||||
editEventId: editEvent?.eventId,
|
||||
parseCommands: parseCommands);
|
||||
sendController.value = TextEditingValue(
|
||||
text: pendingText,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
inputText = pendingText;
|
||||
replyEvent = null;
|
||||
editEvent = null;
|
||||
pendingText = '';
|
||||
});
|
||||
}
|
||||
|
||||
void sendFileAction() async {
|
||||
final result =
|
||||
await FilePickerCross.importFromStorage(type: FileTypeCross.any);
|
||||
if (result == null) return;
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => SendFileDialog(
|
||||
file: MatrixFile(
|
||||
bytes: result.toUint8List(),
|
||||
name: result.fileName,
|
||||
).detectFileType,
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void sendImageAction() async {
|
||||
final result =
|
||||
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
|
||||
if (result == null) return;
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => SendFileDialog(
|
||||
file: MatrixImageFile(
|
||||
bytes: result.toUint8List(),
|
||||
name: result.fileName,
|
||||
),
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void openCameraAction() async {
|
||||
// Make sure the textfield is unfocused before opening the camera
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
final file = await ImagePicker().pickImage(source: ImageSource.camera);
|
||||
if (file == null) return;
|
||||
final bytes = await file.readAsBytes();
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => SendFileDialog(
|
||||
file: MatrixImageFile(
|
||||
bytes: bytes,
|
||||
name: file.path,
|
||||
),
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void sendStickerAction() async {
|
||||
final sticker = await showModalBottomSheet<ImagePackImageContent>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => StickerPickerDialog(room: room),
|
||||
);
|
||||
if (sticker == null) return;
|
||||
final eventContent = <String, dynamic>{
|
||||
'body': sticker.body,
|
||||
if (sticker.info != null) 'info': sticker.info,
|
||||
'url': sticker.url.toString(),
|
||||
};
|
||||
// send the sticker
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.sendEvent(
|
||||
eventContent,
|
||||
type: EventTypes.Sticker,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void voiceMessageAction() async {
|
||||
if (await Record().hasPermission() == false) return;
|
||||
final result = await showDialog<RecordingResult>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => const RecordingDialog(),
|
||||
);
|
||||
if (result == null) return;
|
||||
final audioFile = File(result.path);
|
||||
final file = MatrixAudioFile(
|
||||
bytes: audioFile.readAsBytesSync(),
|
||||
name: audioFile.path,
|
||||
);
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () =>
|
||||
room.sendFileEvent(file, inReplyTo: replyEvent, extraContent: {
|
||||
'info': {
|
||||
...file.info,
|
||||
'duration': result.duration,
|
||||
},
|
||||
'org.matrix.msc3245.voice': {},
|
||||
}),
|
||||
);
|
||||
setState(() {
|
||||
replyEvent = null;
|
||||
});
|
||||
}
|
||||
|
||||
void sendLocationAction() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => SendLocationDialog(room: room),
|
||||
);
|
||||
}
|
||||
|
||||
String _getSelectedEventString() {
|
||||
var copyString = '';
|
||||
if (selectedEvents.length == 1) {
|
||||
return selectedEvents.first
|
||||
.getDisplayEvent(timeline)
|
||||
.getLocalizedBody(MatrixLocals(L10n.of(context)));
|
||||
}
|
||||
for (final event in selectedEvents) {
|
||||
if (copyString.isNotEmpty) copyString += '\n\n';
|
||||
copyString += event.getDisplayEvent(timeline).getLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
withSenderNamePrefix: true);
|
||||
}
|
||||
return copyString;
|
||||
}
|
||||
|
||||
void copyEventsAction() {
|
||||
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
|
||||
setState(() => selectedEvents.clear());
|
||||
}
|
||||
|
||||
void reportEventAction() async {
|
||||
final event = selectedEvents.single;
|
||||
final score = await showConfirmationDialog<int>(
|
||||
context: context,
|
||||
title: L10n.of(context).reportMessage,
|
||||
message: L10n.of(context).howOffensiveIsThisContent,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
okLabel: L10n.of(context).ok,
|
||||
actions: [
|
||||
AlertDialogAction(
|
||||
key: -100,
|
||||
label: L10n.of(context).extremeOffensive,
|
||||
),
|
||||
AlertDialogAction(
|
||||
key: -50,
|
||||
label: L10n.of(context).offensive,
|
||||
),
|
||||
AlertDialogAction(
|
||||
key: 0,
|
||||
label: L10n.of(context).inoffensive,
|
||||
),
|
||||
]);
|
||||
if (score == null) return;
|
||||
final reason = await showTextInputDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context).whyDoYouWantToReportThis,
|
||||
okLabel: L10n.of(context).ok,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
textFields: [DialogTextField(hintText: L10n.of(context).reason)]);
|
||||
if (reason == null || reason.single.isEmpty) return;
|
||||
final result = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => Matrix.of(context).client.reportContent(
|
||||
event.roomId,
|
||||
event.eventId,
|
||||
reason: reason.single,
|
||||
score: score,
|
||||
),
|
||||
);
|
||||
if (result.error != null) return;
|
||||
setState(() => selectedEvents.clear());
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(L10n.of(context).contentHasBeenReported)));
|
||||
}
|
||||
|
||||
void redactEventsAction() async {
|
||||
final confirmed = await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context).messageWillBeRemovedWarning,
|
||||
okLabel: L10n.of(context).remove,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
) ==
|
||||
OkCancelResult.ok;
|
||||
if (!confirmed) return;
|
||||
for (final event in selectedEvents) {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
if (event.status.isSent) {
|
||||
if (event.canRedact) {
|
||||
await event.redactEvent();
|
||||
} else {
|
||||
final client = currentRoomBundle.firstWhere(
|
||||
(cl) => selectedEvents.first.senderId == cl.userID,
|
||||
orElse: () => null);
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
final room = client.getRoomById(roomId);
|
||||
await Event.fromJson(event.toJson(), room).redactEvent();
|
||||
}
|
||||
} else {
|
||||
await event.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
setState(() => selectedEvents.clear());
|
||||
}
|
||||
|
||||
List<Client> get currentRoomBundle {
|
||||
final clients = matrix.currentBundle;
|
||||
clients.removeWhere((c) => c.getRoomById(roomId) == null);
|
||||
return clients;
|
||||
}
|
||||
|
||||
bool get canRedactSelectedEvents {
|
||||
final clients = matrix.currentBundle;
|
||||
for (final event in selectedEvents) {
|
||||
if (event.canRedact == false &&
|
||||
!(clients.any((cl) => event.senderId == cl.userID))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get canEditSelectedEvents {
|
||||
if (selectedEvents.length != 1 || !selectedEvents.first.status.isSent) {
|
||||
return false;
|
||||
}
|
||||
return currentRoomBundle
|
||||
.any((cl) => selectedEvents.first.senderId == cl.userID);
|
||||
}
|
||||
|
||||
void forwardEventsAction() async {
|
||||
if (selectedEvents.length == 1) {
|
||||
Matrix.of(context).shareContent = selectedEvents.first.content;
|
||||
} else {
|
||||
Matrix.of(context).shareContent = {
|
||||
'msgtype': 'm.text',
|
||||
'body': _getSelectedEventString(),
|
||||
};
|
||||
}
|
||||
setState(() => selectedEvents.clear());
|
||||
VRouter.of(context).to('/rooms');
|
||||
}
|
||||
|
||||
void sendAgainAction() {
|
||||
final event = selectedEvents.first;
|
||||
if (event.status.isError) {
|
||||
event.sendAgain();
|
||||
}
|
||||
final allEditEvents = event
|
||||
.aggregatedEvents(timeline, RelationshipTypes.edit)
|
||||
.where((e) => e.status.isError);
|
||||
for (final e in allEditEvents) {
|
||||
e.sendAgain();
|
||||
}
|
||||
setState(() => selectedEvents.clear());
|
||||
}
|
||||
|
||||
void replyAction({Event replyTo}) {
|
||||
setState(() {
|
||||
replyEvent = replyTo ?? selectedEvents.first;
|
||||
selectedEvents.clear();
|
||||
});
|
||||
inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
void scrollToEventId(String eventId) async {
|
||||
var eventIndex = filteredEvents.indexWhere((e) => e.eventId == eventId);
|
||||
if (eventIndex == -1) {
|
||||
// event id not found...maybe we can fetch it?
|
||||
// the try...finally is here to start and close the loading dialog reliably
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
// okay, we first have to fetch if the event is in the room
|
||||
try {
|
||||
final event = await timeline.getEventById(eventId);
|
||||
if (event == null) {
|
||||
// event is null...meaning something is off
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err is MatrixException && err.errcode == 'M_NOT_FOUND') {
|
||||
// event wasn't found, as the server gave a 404 or something
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
// okay, we know that the event *is* in the room
|
||||
while (eventIndex == -1) {
|
||||
if (!canLoadMore) {
|
||||
// we can't load any more events but still haven't found ours yet...better stop here
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await timeline.requestHistory(historyCount: _loadHistoryCount);
|
||||
} catch (err) {
|
||||
if (err is TimeoutException) {
|
||||
// loading the history timed out...so let's do nothing
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
eventIndex =
|
||||
filteredEvents.indexWhere((e) => e.eventId == eventId);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await scrollController.scrollToIndex(
|
||||
eventIndex,
|
||||
preferPosition: AutoScrollPosition.middle,
|
||||
);
|
||||
_updateScrollController();
|
||||
}
|
||||
|
||||
void scrollDown() => scrollController.jumpTo(0);
|
||||
|
||||
void onEmojiSelected(_, emoji) {
|
||||
setState(() => showEmojiPicker = false);
|
||||
if (emoji == null) return;
|
||||
// make sure we don't send the same emoji twice
|
||||
if (_allReactionEvents
|
||||
.any((e) => e.content['m.relates_to']['key'] == emoji.emoji)) return;
|
||||
return sendEmojiAction(emoji.emoji);
|
||||
}
|
||||
|
||||
Iterable<Event> _allReactionEvents;
|
||||
|
||||
void cancelEmojiPicker() => setState(() => showEmojiPicker = false);
|
||||
|
||||
void pickEmojiAction(Iterable<Event> allReactionEvents) async {
|
||||
_allReactionEvents = allReactionEvents;
|
||||
setState(() => showEmojiPicker = true);
|
||||
}
|
||||
|
||||
void sendEmojiAction(String emoji) async {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.sendReaction(
|
||||
selectedEvents.single.eventId,
|
||||
emoji,
|
||||
),
|
||||
);
|
||||
setState(() => selectedEvents.clear());
|
||||
}
|
||||
|
||||
void clearSelectedEvents() => setState(() {
|
||||
selectedEvents.clear();
|
||||
showEmojiPicker = false;
|
||||
});
|
||||
|
||||
void editSelectedEventAction() {
|
||||
final client = currentRoomBundle.firstWhere(
|
||||
(cl) => selectedEvents.first.senderId == cl.userID,
|
||||
orElse: () => null);
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
setSendingClient(client);
|
||||
setState(() {
|
||||
pendingText = sendController.text;
|
||||
editEvent = selectedEvents.first;
|
||||
inputText = sendController.text = editEvent
|
||||
.getDisplayEvent(timeline)
|
||||
.getLocalizedBody(MatrixLocals(L10n.of(context)),
|
||||
withSenderNamePrefix: false, hideReply: true);
|
||||
selectedEvents.clear();
|
||||
});
|
||||
inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
void goToNewRoomAction() async {
|
||||
if (OkCancelResult.ok !=
|
||||
await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context).goToTheNewRoom,
|
||||
message: room
|
||||
.getState(EventTypes.RoomTombstone)
|
||||
.parsedTombstoneContent
|
||||
.body,
|
||||
okLabel: L10n.of(context).ok,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final result = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.client.joinRoom(room
|
||||
.getState(EventTypes.RoomTombstone)
|
||||
.parsedTombstoneContent
|
||||
.replacementRoom),
|
||||
);
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: room.leave,
|
||||
);
|
||||
if (result.error == null) {
|
||||
VRouter.of(context).toSegments(['rooms', result.result]);
|
||||
}
|
||||
}
|
||||
|
||||
void onSelectMessage(Event event) {
|
||||
if (!event.redacted) {
|
||||
if (selectedEvents.contains(event)) {
|
||||
setState(
|
||||
() => selectedEvents.remove(event),
|
||||
);
|
||||
} else {
|
||||
setState(
|
||||
() => selectedEvents.add(event),
|
||||
);
|
||||
}
|
||||
selectedEvents.sort(
|
||||
(a, b) => a.originServerTs.compareTo(b.originServerTs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int findChildIndexCallback(Key key, Map<String, int> thisEventsKeyMap) {
|
||||
// this method is called very often. As such, it has to be optimized for speed.
|
||||
if (key is! ValueKey) {
|
||||
return null;
|
||||
}
|
||||
final eventId = (key as ValueKey).value;
|
||||
if (eventId is! String) {
|
||||
return null;
|
||||
}
|
||||
// first fetch the last index the event was at
|
||||
final index = thisEventsKeyMap[eventId];
|
||||
if (index == null) {
|
||||
return null;
|
||||
}
|
||||
// we need to +1 as 0 is the typing thing at the bottom
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
void onInputBarSubmitted(_) {
|
||||
send();
|
||||
FocusScope.of(context).requestFocus(inputFocus);
|
||||
}
|
||||
|
||||
void onAddPopupMenuButtonSelected(String choice) {
|
||||
if (choice == 'file') {
|
||||
sendFileAction();
|
||||
}
|
||||
if (choice == 'image') {
|
||||
sendImageAction();
|
||||
}
|
||||
if (choice == 'camera') {
|
||||
openCameraAction();
|
||||
}
|
||||
if (choice == 'sticker') {
|
||||
sendStickerAction();
|
||||
}
|
||||
if (choice == 'voice') {
|
||||
voiceMessageAction();
|
||||
}
|
||||
if (choice == 'location') {
|
||||
sendLocationAction();
|
||||
}
|
||||
}
|
||||
|
||||
void onInputBarChanged(String text) {
|
||||
if (text.endsWith(' ') && matrix.hasComplexBundles) {
|
||||
final clients = currentRoomBundle;
|
||||
for (final client in clients) {
|
||||
final prefix = client.sendPrefix;
|
||||
if ((prefix?.isNotEmpty ?? false) &&
|
||||
text.toLowerCase() == '${prefix.toLowerCase()} ') {
|
||||
setSendingClient(client);
|
||||
setState(() {
|
||||
inputText = '';
|
||||
sendController.text = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
typingCoolDown?.cancel();
|
||||
typingCoolDown = Timer(const Duration(seconds: 2), () {
|
||||
typingCoolDown = null;
|
||||
currentlyTyping = false;
|
||||
room.setTyping(false);
|
||||
});
|
||||
typingTimeout ??= Timer(const Duration(seconds: 30), () {
|
||||
typingTimeout = null;
|
||||
currentlyTyping = false;
|
||||
});
|
||||
if (!currentlyTyping) {
|
||||
currentlyTyping = true;
|
||||
room.setTyping(true, timeout: const Duration(seconds: 30).inMilliseconds);
|
||||
}
|
||||
setState(() => inputText = text);
|
||||
}
|
||||
|
||||
void cancelReplyEventAction() => setState(() {
|
||||
if (editEvent != null) {
|
||||
inputText = sendController.text = pendingText;
|
||||
pendingText = '';
|
||||
}
|
||||
replyEvent = null;
|
||||
editEvent = null;
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ChatView(this);
|
||||
}
|
||||
885
lib/pages/chat/chat_view.dart
Normal file
885
lib/pages/chat/chat_view.dart
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:swipe_to_action/swipe_to_action.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/app_emojis.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pages/chat/chat.dart';
|
||||
import 'package:fluffychat/pages/chat/encryption_button.dart';
|
||||
import 'package:fluffychat/pages/chat/input_bar.dart';
|
||||
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/utils/room_status_extension.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/chat_settings_popup_menu.dart';
|
||||
import 'package:fluffychat/widgets/connection_status_header.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:fluffychat/widgets/unread_badge_back_button.dart';
|
||||
import '../../utils/stream_extension.dart';
|
||||
import 'events/message.dart';
|
||||
import 'events/reply_content.dart';
|
||||
|
||||
class ChatView extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const ChatView(this.controller, {Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
controller.matrix ??= Matrix.of(context);
|
||||
final client = controller.matrix.client;
|
||||
controller.sendingClient ??= client;
|
||||
controller.room = controller.sendingClient.getRoomById(controller.roomId);
|
||||
if (controller.room == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(L10n.of(context).oopsSomethingWentWrong),
|
||||
),
|
||||
body: Center(
|
||||
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.room.membership == Membership.invite) {
|
||||
showFutureLoadingDialog(
|
||||
context: context, future: () => controller.room.join());
|
||||
}
|
||||
|
||||
return VWidgetGuard(
|
||||
onSystemPop: (redirector) async {
|
||||
if (controller.selectedEvents.isNotEmpty) {
|
||||
controller.clearSelectedEvents();
|
||||
redirector.stopRedirection();
|
||||
}
|
||||
},
|
||||
child: StreamBuilder(
|
||||
stream: controller.room.onUpdate.stream
|
||||
.rateLimit(const Duration(milliseconds: 250)),
|
||||
builder: (context, snapshot) => Scaffold(
|
||||
appBar: AppBar(
|
||||
actionsIconTheme: IconThemeData(
|
||||
color: controller.selectedEvents.isEmpty
|
||||
? null
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
leading: controller.selectMode
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.clearSelectedEvents,
|
||||
tooltip: L10n.of(context).close,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: UnreadBadgeBackButton(roomId: controller.roomId),
|
||||
titleSpacing: 0,
|
||||
title: controller.selectedEvents.isEmpty
|
||||
? ListTile(
|
||||
leading: Avatar(
|
||||
controller.room.avatar, controller.room.displayname),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onTap: controller.room.isDirectChat
|
||||
? () => showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (c) => UserBottomSheet(
|
||||
user: controller.room.getUserByMXIDSync(
|
||||
controller.room.directChatMatrixID),
|
||||
outerContext: context,
|
||||
onMention: () => controller
|
||||
.sendController.text +=
|
||||
'${controller.room.getUserByMXIDSync(controller.room.directChatMatrixID).mention} ',
|
||||
),
|
||||
)
|
||||
: () => VRouter.of(context).toSegments(
|
||||
['rooms', controller.room.id, 'details']),
|
||||
title: Text(
|
||||
controller.room.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context))),
|
||||
maxLines: 1),
|
||||
subtitle: controller.room
|
||||
.getLocalizedTypingText(context)
|
||||
.isEmpty
|
||||
? StreamBuilder<Object>(
|
||||
stream: Matrix.of(context)
|
||||
.client
|
||||
.onPresence
|
||||
.stream
|
||||
.where((p) =>
|
||||
p.senderId ==
|
||||
controller.room.directChatMatrixID)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, snapshot) => Text(
|
||||
controller.room.getLocalizedStatus(context),
|
||||
maxLines: 1,
|
||||
//overflow: TextOverflow.ellipsis,
|
||||
))
|
||||
: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.edit_outlined,
|
||||
color:
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
size: 13),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.room
|
||||
.getLocalizedTypingText(context),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(controller.selectedEvents.length.toString()),
|
||||
actions: controller.selectMode
|
||||
? <Widget>[
|
||||
if (controller.canEditSelectedEvents)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: L10n.of(context).edit,
|
||||
onPressed: controller.editSelectedEventAction,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_outlined),
|
||||
tooltip: L10n.of(context).copy,
|
||||
onPressed: controller.copyEventsAction,
|
||||
),
|
||||
if (controller.selectedEvents.length == 1)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: L10n.of(context).reportMessage,
|
||||
onPressed: controller.reportEventAction,
|
||||
),
|
||||
if (controller.canRedactSelectedEvents)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
tooltip: L10n.of(context).redactMessage,
|
||||
onPressed: controller.redactEventsAction,
|
||||
),
|
||||
]
|
||||
: <Widget>[
|
||||
if (controller.room.canSendDefaultStates)
|
||||
IconButton(
|
||||
tooltip: L10n.of(context).videoCall,
|
||||
icon: const Icon(Icons.video_call_outlined),
|
||||
onPressed: controller.startCallAction,
|
||||
),
|
||||
ChatSettingsPopupMenu(
|
||||
controller.room, !controller.room.isDirectChat),
|
||||
],
|
||||
),
|
||||
floatingActionButton: controller.showScrollDownButton
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 56.0),
|
||||
child: FloatingActionButton(
|
||||
onPressed: controller.scrollDown,
|
||||
foregroundColor:
|
||||
Theme.of(context).textTheme.bodyText2.color,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
mini: true,
|
||||
child: Icon(Icons.arrow_downward_outlined,
|
||||
color: Theme.of(context).primaryColor),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
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>[
|
||||
if (controller.room.getState(EventTypes.RoomTombstone) !=
|
||||
null)
|
||||
SizedBox(
|
||||
height: 72,
|
||||
child: Material(
|
||||
color: Theme.of(context).secondaryHeaderColor,
|
||||
elevation: 1,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
backgroundColor:
|
||||
Theme.of(context).backgroundColor,
|
||||
child: const Icon(Icons.upgrade_outlined),
|
||||
),
|
||||
title: Text(
|
||||
controller.room
|
||||
.getState(EventTypes.RoomTombstone)
|
||||
.parsedTombstoneContent
|
||||
.body,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(L10n.of(context).goToTheNewRoom),
|
||||
onTap: controller.goToNewRoomAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<bool>(
|
||||
future: controller.getTimeline(),
|
||||
builder: (BuildContext context, snapshot) {
|
||||
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;
|
||||
}
|
||||
final seenByText =
|
||||
controller.room.getLocalizedSeenByText(
|
||||
context,
|
||||
controller.timeline,
|
||||
controller.filteredEvents,
|
||||
controller.unfolded,
|
||||
);
|
||||
|
||||
return ListView.custom(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom: 4,
|
||||
),
|
||||
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),
|
||||
),
|
||||
)
|
||||
: Container()
|
||||
: i == 0
|
||||
? AnimatedContainer(
|
||||
height: seenByText.isEmpty ? 0 : 24,
|
||||
duration: seenByText.isEmpty
|
||||
? const Duration(
|
||||
milliseconds: 0)
|
||||
: const Duration(
|
||||
milliseconds: 300),
|
||||
alignment: controller.filteredEvents
|
||||
.isNotEmpty &&
|
||||
controller.filteredEvents
|
||||
.first.senderId ==
|
||||
client.userID
|
||||
? Alignment.topRight
|
||||
: Alignment.topLeft,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.scaffoldBackgroundColor
|
||||
.withOpacity(0.8),
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
seenByText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: AutoScrollTag(
|
||||
key: ValueKey(controller
|
||||
.filteredEvents[i - 1].eventId),
|
||||
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],
|
||||
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
|
||||
.contains(
|
||||
controller.filteredEvents[
|
||||
i - 1]),
|
||||
timeline: controller.timeline,
|
||||
nextEvent: i >= 2
|
||||
? controller.filteredEvents[i - 2]
|
||||
: null),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: controller.filteredEvents.length + 2,
|
||||
findChildIndexCallback: (key) =>
|
||||
controller.findChildIndexCallback(
|
||||
key, thisEventsKeyMap),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const ConnectionStatusHeader(),
|
||||
if (!controller.showEmojiPicker)
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: (controller.editEvent == null &&
|
||||
controller.replyEvent == null &&
|
||||
controller.room.canSendDefaultMessages &&
|
||||
controller.selectedEvents.length == 1)
|
||||
? 56
|
||||
: 0,
|
||||
child: Material(
|
||||
color: Theme.of(context).secondaryHeaderColor,
|
||||
child: Builder(builder: (context) {
|
||||
if (!(controller.editEvent == null &&
|
||||
controller.replyEvent == null &&
|
||||
controller.selectedEvents.length == 1)) {
|
||||
return Container();
|
||||
}
|
||||
final emojis = List<String>.from(AppEmojis.emojis);
|
||||
final allReactionEvents = controller
|
||||
.selectedEvents.first
|
||||
.aggregatedEvents(controller.timeline,
|
||||
RelationshipTypes.reaction)
|
||||
?.where((event) =>
|
||||
event.senderId ==
|
||||
event.room.client.userID &&
|
||||
event.type == 'm.reaction');
|
||||
|
||||
for (final event in allReactionEvents) {
|
||||
try {
|
||||
emojis.remove(
|
||||
event.content['m.relates_to']['key']);
|
||||
} catch (_) {}
|
||||
}
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: emojis.length + 1,
|
||||
itemBuilder: (c, i) => i == emojis.length
|
||||
? InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => controller
|
||||
.pickEmojiAction(allReactionEvents),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(Icons.add_outlined),
|
||||
),
|
||||
)
|
||||
: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () =>
|
||||
controller.sendEmojiAction(emojis[i]),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
emojis[i],
|
||||
style: const TextStyle(fontSize: 30),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: controller.editEvent != null ||
|
||||
controller.replyEvent != null
|
||||
? 56
|
||||
: 0,
|
||||
child: Material(
|
||||
color: Theme.of(context).secondaryHeaderColor,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
tooltip: L10n.of(context).close,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.cancelReplyEventAction,
|
||||
),
|
||||
Expanded(
|
||||
child: controller.replyEvent != null
|
||||
? ReplyContent(controller.replyEvent,
|
||||
timeline: controller.timeline)
|
||||
: _EditContent(controller.editEvent
|
||||
?.getDisplayEvent(controller.timeline)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.showScrollDownButton)
|
||||
const Divider(
|
||||
height: 1,
|
||||
),
|
||||
if (controller.room.canSendDefaultMessages &&
|
||||
controller.room.membership == Membership.join &&
|
||||
!controller.showEmojiPicker)
|
||||
Padding(
|
||||
padding: EdgeInsets.all(
|
||||
FluffyThemes.isColumnMode(context) ? 16.0 : 8.0),
|
||||
child: Material(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppConfig.borderRadius),
|
||||
elevation: 4,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: controller.selectMode
|
||||
? <Widget>[
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
controller.forwardEventsAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons
|
||||
.keyboard_arrow_left_outlined),
|
||||
Text(L10n.of(context).forward),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
controller.selectedEvents.length == 1
|
||||
? controller.selectedEvents.first
|
||||
.getDisplayEvent(
|
||||
controller.timeline)
|
||||
.status
|
||||
.isSent
|
||||
? SizedBox(
|
||||
height: 56,
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
controller.replyAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(L10n.of(context)
|
||||
.reply),
|
||||
const Icon(Icons
|
||||
.keyboard_arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: 56,
|
||||
child: TextButton(
|
||||
onPressed: controller
|
||||
.sendAgainAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(L10n.of(context)
|
||||
.tryToSendAgain),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.send_outlined,
|
||||
size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
]
|
||||
: <Widget>[
|
||||
AnimatedContainer(
|
||||
duration:
|
||||
const Duration(milliseconds: 200),
|
||||
height: 56,
|
||||
width:
|
||||
controller.inputText.isEmpty ? 56 : 0,
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.add_outlined),
|
||||
onSelected: controller
|
||||
.onAddPopupMenuButtonSelected,
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
PopupMenuItem<String>(
|
||||
value: 'file',
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
child: Icon(
|
||||
Icons.attachment_outlined),
|
||||
),
|
||||
title: Text(
|
||||
L10n.of(context).sendFile),
|
||||
contentPadding:
|
||||
const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'image',
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
child:
|
||||
Icon(Icons.image_outlined),
|
||||
),
|
||||
title: Text(
|
||||
L10n.of(context).sendImage),
|
||||
contentPadding:
|
||||
const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
if (PlatformInfos.isMobile)
|
||||
PopupMenuItem<String>(
|
||||
value: 'camera',
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor:
|
||||
Colors.purple,
|
||||
foregroundColor: Colors.white,
|
||||
child: Icon(Icons
|
||||
.camera_alt_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context)
|
||||
.openCamera),
|
||||
contentPadding:
|
||||
const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
if (controller.room
|
||||
.getImagePacks(
|
||||
ImagePackUsage.sticker)
|
||||
.isNotEmpty)
|
||||
PopupMenuItem<String>(
|
||||
value: 'sticker',
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor:
|
||||
Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
child: Icon(Icons
|
||||
.emoji_emotions_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context)
|
||||
.sendSticker),
|
||||
contentPadding:
|
||||
const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
if (PlatformInfos.isMobile)
|
||||
PopupMenuItem<String>(
|
||||
value: 'voice',
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
child: Icon(
|
||||
Icons.mic_none_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context)
|
||||
.voiceMessage),
|
||||
contentPadding:
|
||||
const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
if (PlatformInfos.isMobile)
|
||||
PopupMenuItem<String>(
|
||||
value: 'location',
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.brown,
|
||||
foregroundColor: Colors.white,
|
||||
child: Icon(
|
||||
Icons.gps_fixed_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context)
|
||||
.shareLocation),
|
||||
contentPadding:
|
||||
const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: EncryptionButton(controller.room),
|
||||
),
|
||||
if (controller.matrix.isMultiAccount &&
|
||||
controller.matrix.hasComplexBundles &&
|
||||
controller.matrix.currentBundle.length >
|
||||
1)
|
||||
Container(
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: _ChatAccountPicker(controller),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4.0),
|
||||
child: InputBar(
|
||||
room: controller.room,
|
||||
minLines: 1,
|
||||
maxLines: 8,
|
||||
autofocus: !PlatformInfos.isMobile,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: AppConfig.sendOnEnter
|
||||
? TextInputAction.send
|
||||
: null,
|
||||
onSubmitted:
|
||||
controller.onInputBarSubmitted,
|
||||
focusNode: controller.inputFocus,
|
||||
controller: controller.sendController,
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
L10n.of(context).writeAMessage,
|
||||
hintMaxLines: 1,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
onChanged:
|
||||
controller.onInputBarChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (PlatformInfos.isMobile &&
|
||||
controller.inputText.isEmpty)
|
||||
Container(
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: IconButton(
|
||||
tooltip:
|
||||
L10n.of(context).voiceMessage,
|
||||
icon: const Icon(
|
||||
Icons.mic_none_outlined),
|
||||
onPressed:
|
||||
controller.voiceMessageAction,
|
||||
),
|
||||
),
|
||||
if (!PlatformInfos.isMobile ||
|
||||
controller.inputText.isNotEmpty)
|
||||
Container(
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
onPressed: controller.send,
|
||||
tooltip: L10n.of(context).send,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: controller.showEmojiPicker
|
||||
? MediaQuery.of(context).size.height / 2
|
||||
: 0,
|
||||
child: controller.showEmojiPicker
|
||||
? EmojiPicker(
|
||||
onEmojiSelected: controller.onEmojiSelected,
|
||||
onBackspacePressed: controller.cancelEmojiPicker,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditContent extends StatelessWidget {
|
||||
final Event event;
|
||||
|
||||
const _EditContent(this.event);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (event == null) {
|
||||
return Container();
|
||||
}
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.edit,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
Container(width: 15.0),
|
||||
Text(
|
||||
event?.getLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
withSenderNamePrefix: false,
|
||||
hideReply: true,
|
||||
) ??
|
||||
'',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.bodyText2.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatAccountPicker extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const _ChatAccountPicker(this.controller, {Key key}) : super(key: key);
|
||||
|
||||
void _popupMenuButtonSelected(String mxid) {
|
||||
final client = controller.matrix.currentBundle
|
||||
.firstWhere((cl) => cl.userID == mxid, orElse: () => null);
|
||||
if (client == null) {
|
||||
Logs().w('Attempted to switch to a non-existing client $mxid');
|
||||
return;
|
||||
}
|
||||
controller.setSendingClient(client);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
controller.matrix ??= Matrix.of(context);
|
||||
final clients = controller.currentRoomBundle;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FutureBuilder<Profile>(
|
||||
future: controller.sendingClient.ownProfile,
|
||||
builder: (context, snapshot) => PopupMenuButton<String>(
|
||||
onSelected: _popupMenuButtonSelected,
|
||||
itemBuilder: (BuildContext context) => clients
|
||||
.map((client) => PopupMenuItem<String>(
|
||||
value: client.userID,
|
||||
child: FutureBuilder<Profile>(
|
||||
future: client.ownProfile,
|
||||
builder: (context, snapshot) => ListTile(
|
||||
leading: Avatar(
|
||||
snapshot.data?.avatarUrl,
|
||||
snapshot.data?.displayName ?? client.userID.localpart,
|
||||
size: 20,
|
||||
),
|
||||
title:
|
||||
Text(snapshot.data?.displayName ?? client.userID),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
child: Avatar(
|
||||
snapshot.data?.avatarUrl,
|
||||
snapshot.data?.displayName ??
|
||||
controller.matrix.client.userID.localpart,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/pages/chat/encryption_button.dart
Normal file
115
lib/pages/chat/encryption_button.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import '../../widgets/matrix.dart';
|
||||
|
||||
class EncryptionButton extends StatefulWidget {
|
||||
final Room room;
|
||||
const EncryptionButton(this.room, {Key key}) : super(key: key);
|
||||
@override
|
||||
_EncryptionButtonState createState() => _EncryptionButtonState();
|
||||
}
|
||||
|
||||
class _EncryptionButtonState extends State<EncryptionButton> {
|
||||
StreamSubscription _onSyncSub;
|
||||
|
||||
void _enableEncryptionAction() async {
|
||||
if (widget.room.encrypted) {
|
||||
VRouter.of(context).toSegments(['rooms', widget.room.id, 'encryption']);
|
||||
return;
|
||||
}
|
||||
if (widget.room.joinRules == JoinRules.public) {
|
||||
await showOkAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
okLabel: L10n.of(context).ok,
|
||||
message: L10n.of(context).noEncryptionForPublicRooms,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context).enableEncryption,
|
||||
message: widget.room.client.encryptionEnabled
|
||||
? L10n.of(context).enableEncryptionWarning
|
||||
: L10n.of(context).needPantalaimonWarning,
|
||||
okLabel: L10n.of(context).yes,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
) ==
|
||||
OkCancelResult.ok) {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => widget.room.enableEncryption(),
|
||||
);
|
||||
// we want to enable the lock icon
|
||||
setState(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_onSyncSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.room.encrypted) {
|
||||
_onSyncSub ??= Matrix.of(context)
|
||||
.client
|
||||
.onSync
|
||||
.stream
|
||||
.where((s) => s.deviceLists != null)
|
||||
.listen((s) => setState(() => null));
|
||||
}
|
||||
return FutureBuilder<List<User>>(
|
||||
future:
|
||||
widget.room.encrypted ? widget.room.requestParticipants() : null,
|
||||
builder: (BuildContext context, snapshot) {
|
||||
Color color;
|
||||
if (widget.room.encrypted && snapshot.hasData) {
|
||||
final users = snapshot.data;
|
||||
users.removeWhere((u) =>
|
||||
!{Membership.invite, Membership.join}.contains(u.membership) ||
|
||||
!widget.room.client.userDeviceKeys.containsKey(u.id));
|
||||
var allUsersValid = true;
|
||||
var oneUserInvalid = false;
|
||||
for (final u in users) {
|
||||
final status = widget.room.client.userDeviceKeys[u.id].verified;
|
||||
if (status != UserVerifiedStatus.verified) {
|
||||
allUsersValid = false;
|
||||
}
|
||||
if (status == UserVerifiedStatus.unknownDevice) {
|
||||
oneUserInvalid = true;
|
||||
}
|
||||
}
|
||||
color = oneUserInvalid
|
||||
? Colors.red
|
||||
: (allUsersValid ? Colors.green : Colors.orange);
|
||||
} else if (!widget.room.encrypted &&
|
||||
widget.room.joinRules != JoinRules.public) {
|
||||
color = Colors.red;
|
||||
}
|
||||
return IconButton(
|
||||
tooltip: widget.room.encrypted
|
||||
? L10n.of(context).encrypted
|
||||
: L10n.of(context).encryptionNotEnabled,
|
||||
icon: Icon(
|
||||
widget.room.encrypted
|
||||
? Icons.lock_outlined
|
||||
: Icons.lock_open_outlined,
|
||||
size: 20,
|
||||
color: color),
|
||||
onPressed: _enableEncryptionAction,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
197
lib/pages/chat/events/audio_player.dart
Normal file
197
lib/pages/chat/events/audio_player.dart
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:fluffychat/utils/sentry_controller.dart';
|
||||
import '../../../utils/matrix_sdk_extensions.dart/event_extension.dart';
|
||||
|
||||
class AudioPlayerWidget extends StatefulWidget {
|
||||
final Color color;
|
||||
final Event event;
|
||||
|
||||
static String currentId;
|
||||
|
||||
const AudioPlayerWidget(this.event, {this.color = Colors.black, Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
_AudioPlayerState createState() => _AudioPlayerState();
|
||||
}
|
||||
|
||||
enum AudioPlayerStatus { notDownloaded, downloading, downloaded }
|
||||
|
||||
class _AudioPlayerState extends State<AudioPlayerWidget> {
|
||||
AudioPlayerStatus status = AudioPlayerStatus.notDownloaded;
|
||||
final AudioPlayer audioPlayer = AudioPlayer();
|
||||
|
||||
StreamSubscription onAudioPositionChanged;
|
||||
StreamSubscription onDurationChanged;
|
||||
StreamSubscription onPlayerStateChanged;
|
||||
StreamSubscription onPlayerError;
|
||||
|
||||
String statusText;
|
||||
double currentPosition = 0;
|
||||
double maxPosition = 0;
|
||||
|
||||
File audioFile;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (audioPlayer.state == PlayerState.PLAYING) {
|
||||
audioPlayer.stop();
|
||||
}
|
||||
onAudioPositionChanged?.cancel();
|
||||
onDurationChanged?.cancel();
|
||||
onPlayerStateChanged?.cancel();
|
||||
onPlayerError?.cancel();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _downloadAction() async {
|
||||
if (status != AudioPlayerStatus.notDownloaded) return;
|
||||
setState(() => status = AudioPlayerStatus.downloading);
|
||||
try {
|
||||
final matrixFile =
|
||||
await widget.event.downloadAndDecryptAttachmentCached();
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName =
|
||||
widget.event.content.tryGet<String>('filename') ?? matrixFile.name;
|
||||
final file = File('${tempDir.path}/$fileName');
|
||||
await file.writeAsBytes(matrixFile.bytes);
|
||||
|
||||
setState(() {
|
||||
audioFile = file;
|
||||
status = AudioPlayerStatus.downloaded;
|
||||
});
|
||||
_playAction();
|
||||
} catch (e, s) {
|
||||
Logs().v('Could not download audio file', e, s);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _playAction() async {
|
||||
if (AudioPlayerWidget.currentId != widget.event.eventId) {
|
||||
if (AudioPlayerWidget.currentId != null) {
|
||||
if (audioPlayer.state != PlayerState.STOPPED) {
|
||||
await audioPlayer.stop();
|
||||
setState(() => null);
|
||||
}
|
||||
}
|
||||
AudioPlayerWidget.currentId = widget.event.eventId;
|
||||
}
|
||||
switch (audioPlayer.state) {
|
||||
case PlayerState.PLAYING:
|
||||
await audioPlayer.pause();
|
||||
break;
|
||||
case PlayerState.PAUSED:
|
||||
await audioPlayer.resume();
|
||||
break;
|
||||
case PlayerState.STOPPED:
|
||||
default:
|
||||
onAudioPositionChanged ??=
|
||||
audioPlayer.onAudioPositionChanged.listen((state) {
|
||||
setState(() {
|
||||
statusText =
|
||||
'${state.inMinutes.toString().padLeft(2, '0')}:${(state.inSeconds % 60).toString().padLeft(2, '0')}';
|
||||
currentPosition = state.inMilliseconds.toDouble();
|
||||
});
|
||||
});
|
||||
onDurationChanged ??= audioPlayer.onDurationChanged.listen((max) =>
|
||||
setState(() => maxPosition = max.inMilliseconds.toDouble()));
|
||||
onPlayerStateChanged ??= audioPlayer.onPlayerStateChanged
|
||||
.listen((_) => setState(() => null));
|
||||
onPlayerError ??= audioPlayer.onPlayerError.listen((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context).oopsSomethingWentWrong),
|
||||
),
|
||||
);
|
||||
SentryController.captureException(e, StackTrace.current);
|
||||
});
|
||||
|
||||
await audioPlayer.play(audioFile.path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static const double buttonSize = 36;
|
||||
|
||||
String get _durationString {
|
||||
final durationInt = widget.event.content
|
||||
.tryGetMap<String, dynamic>('info')
|
||||
?.tryGet<int>('duration');
|
||||
if (durationInt == null) return null;
|
||||
final duration = Duration(milliseconds: durationInt);
|
||||
return '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
statusText ??= _durationString ?? '00:00';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
child: status == AudioPlayerStatus.downloading
|
||||
? CircularProgressIndicator(strokeWidth: 2, color: widget.color)
|
||||
: InkWell(
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
child: Material(
|
||||
color: widget.color.withAlpha(64),
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
child: Icon(
|
||||
audioPlayer.state == PlayerState.PLAYING
|
||||
? Icons.pause_outlined
|
||||
: Icons.play_arrow_outlined,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
onLongPress: () => widget.event.saveFile(context),
|
||||
onTap: () {
|
||||
if (status == AudioPlayerStatus.downloaded) {
|
||||
_playAction();
|
||||
} else {
|
||||
_downloadAction();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
activeColor: Theme.of(context).colorScheme.secondaryVariant,
|
||||
inactiveColor: widget.color.withAlpha(64),
|
||||
value: currentPosition,
|
||||
onChanged: (double position) =>
|
||||
audioPlayer.seek(Duration(milliseconds: position.toInt())),
|
||||
max: status == AudioPlayerStatus.downloaded ? maxPosition : 0,
|
||||
min: 0,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
154
lib/pages/chat/events/html_message.dart
Normal file
154
lib/pages/chat/events/html_message.dart
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:flutter_matrix_html/flutter_html.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../config/setting_keys.dart';
|
||||
import '../../../pages/image_viewer/image_viewer.dart';
|
||||
import '../../../utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||
import '../../../utils/url_launcher.dart';
|
||||
|
||||
class HtmlMessage extends StatelessWidget {
|
||||
final String html;
|
||||
final int maxLines;
|
||||
final Room room;
|
||||
final TextStyle defaultTextStyle;
|
||||
final TextStyle linkStyle;
|
||||
final double emoteSize;
|
||||
|
||||
const HtmlMessage({
|
||||
Key key,
|
||||
this.html,
|
||||
this.maxLines,
|
||||
this.room,
|
||||
this.defaultTextStyle,
|
||||
this.linkStyle,
|
||||
this.emoteSize,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// riot-web is notorious for creating bad reply fallback events from invalid messages which, if
|
||||
// not handled properly, can lead to impersination. As such, we strip the entire `<mx-reply>` tags
|
||||
// here already, to prevent that from happening.
|
||||
// We do *not* do this in an AST and just with simple regex here, as riot-web tends to create
|
||||
// miss-matching tags, and this way we actually correctly identify what we want to strip and, well,
|
||||
// strip it.
|
||||
final renderHtml = html.replaceAll(
|
||||
RegExp('<mx-reply>.*</mx-reply>',
|
||||
caseSensitive: false, multiLine: false, dotAll: true),
|
||||
'');
|
||||
|
||||
// there is no need to pre-validate the html, as we validate it while rendering
|
||||
|
||||
final matrix = Matrix.of(context);
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
return Html(
|
||||
data: renderHtml,
|
||||
defaultTextStyle: defaultTextStyle,
|
||||
emoteSize: emoteSize,
|
||||
linkStyle: linkStyle ??
|
||||
themeData.textTheme.bodyText2.copyWith(
|
||||
color: themeData.colorScheme.secondary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
shrinkToFit: true,
|
||||
maxLines: maxLines,
|
||||
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
|
||||
onPillTap: (url) => UrlLauncher(context, url).launchUrl(),
|
||||
getMxcUrl: (String mxc, double width, double height,
|
||||
{bool animated = false}) {
|
||||
final ratio = MediaQuery.of(context).devicePixelRatio;
|
||||
return Uri.parse(mxc)
|
||||
?.getThumbnail(
|
||||
matrix.client,
|
||||
width: (width ?? 800) * ratio,
|
||||
height: (height ?? 800) * ratio,
|
||||
method: ThumbnailMethod.scale,
|
||||
animated: AppConfig.autoplayImages ? animated : false,
|
||||
)
|
||||
.toString();
|
||||
},
|
||||
onImageTap: (String mxc) => showDialog(
|
||||
context: Matrix.of(context).navigatorContext,
|
||||
useRootNavigator: false,
|
||||
builder: (_) => ImageViewer(Event.fromJson({
|
||||
'type': EventTypes.Message,
|
||||
'content': <String, dynamic>{
|
||||
'body': mxc,
|
||||
'url': mxc,
|
||||
'msgtype': MessageTypes.Image,
|
||||
},
|
||||
'event_id': 'fake_event',
|
||||
}, room))),
|
||||
setCodeLanguage: (String key, String value) async {
|
||||
await matrix.store.setItem('${SettingKeys.codeLanguage}.$key', value);
|
||||
},
|
||||
getCodeLanguage: (String key) async {
|
||||
return await matrix.store.getItem('${SettingKeys.codeLanguage}.$key');
|
||||
},
|
||||
getPillInfo: (String url) async {
|
||||
if (room == null) {
|
||||
return null;
|
||||
}
|
||||
final identityParts = url.parseIdentifierIntoParts();
|
||||
final identifier = identityParts?.primaryIdentifier;
|
||||
if (identifier == null) {
|
||||
return null;
|
||||
}
|
||||
if (identifier.sigil == '@') {
|
||||
// we have a user pill
|
||||
final user = room.getState('m.room.member', identifier);
|
||||
if (user != null) {
|
||||
return user.content;
|
||||
}
|
||||
// there might still be a profile...
|
||||
final profile = await room.client.getProfileFromUserId(identifier);
|
||||
if (profile != null) {
|
||||
return {
|
||||
'displayname': profile.displayName,
|
||||
'avatar_url': profile.avatarUrl.toString(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (identifier.sigil == '#') {
|
||||
// we have an alias pill
|
||||
for (final r in room.client.rooms) {
|
||||
final state = r.getState('m.room.canonical_alias');
|
||||
if (state != null &&
|
||||
((state.content['alias'] is String &&
|
||||
state.content['alias'] == identifier) ||
|
||||
(state.content['alt_aliases'] is List &&
|
||||
state.content['alt_aliases'].contains(identifier)))) {
|
||||
// we have a room!
|
||||
return {
|
||||
'displayname':
|
||||
r.getLocalizedDisplayname(MatrixLocals(L10n.of(context))),
|
||||
'avatar_url': r.getState('m.room.avatar')?.content['url'],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (identifier.sigil == '!') {
|
||||
// we have a room ID pill
|
||||
final r = room.client.getRoomById(identifier);
|
||||
if (r == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'displayname':
|
||||
r.getLocalizedDisplayname(MatrixLocals(L10n.of(context))),
|
||||
'avatar_url': r.getState('m.room.avatar')?.content['url'],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
464
lib/pages/chat/events/image_bubble.dart
Normal file
464
lib/pages/chat/events/image_bubble.dart
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pages/image_viewer/image_viewer.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../../../utils/matrix_sdk_extensions.dart/event_extension.dart';
|
||||
|
||||
class ImageBubble extends StatefulWidget {
|
||||
final Event event;
|
||||
final bool tapToView;
|
||||
final BoxFit fit;
|
||||
final bool maxSize;
|
||||
final Color backgroundColor;
|
||||
final double radius;
|
||||
final bool thumbnailOnly;
|
||||
final bool animated;
|
||||
final double width;
|
||||
final double height;
|
||||
final void Function() onLoaded;
|
||||
final void Function() onTap;
|
||||
|
||||
const ImageBubble(
|
||||
this.event, {
|
||||
this.tapToView = true,
|
||||
this.maxSize = true,
|
||||
this.backgroundColor,
|
||||
this.fit = BoxFit.cover,
|
||||
this.radius = 10.0,
|
||||
this.thumbnailOnly = true,
|
||||
this.onLoaded,
|
||||
this.width = 400,
|
||||
this.height = 300,
|
||||
this.animated = false,
|
||||
this.onTap,
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_ImageBubbleState createState() => _ImageBubbleState();
|
||||
}
|
||||
|
||||
class _ImageBubbleState extends State<ImageBubble> {
|
||||
// for plaintext: holds the http URL for the thumbnail
|
||||
String thumbnailUrl;
|
||||
// for plaintext: holds the http URL of the original
|
||||
String attachmentUrl;
|
||||
MatrixFile _file;
|
||||
MatrixFile _thumbnail;
|
||||
bool _requestedThumbnailOnFailure = false;
|
||||
// In case we have animated = false, this will hold the first frame so that we make
|
||||
// sure that things are never animated
|
||||
Widget _firstFrame;
|
||||
|
||||
// the mimetypes that we know how to render, from the flutter Image widget
|
||||
final _knownMimetypes = <String>{
|
||||
'image/jpg',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/apng',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/x-bmp',
|
||||
};
|
||||
|
||||
// overrides for certain mimetypes if they need different images to render
|
||||
// memory are for in-memory renderers (e2ee rooms), network for network url renderers.
|
||||
// The map values themself are set in initState() as they need to be able to access
|
||||
// `this`.
|
||||
final _contentRenderers = <String, _ImageBubbleContentRenderer>{};
|
||||
|
||||
String getMimetype([bool thumbnail = false]) => thumbnail
|
||||
? widget.event.thumbnailMimetype.toLowerCase()
|
||||
: widget.event.attachmentMimetype.toLowerCase();
|
||||
|
||||
MatrixFile get _displayFile => _file ?? _thumbnail;
|
||||
String get displayUrl => widget.thumbnailOnly ? thumbnailUrl : attachmentUrl;
|
||||
|
||||
dynamic _error;
|
||||
|
||||
Future<void> _requestFile({bool getThumbnail = false}) async {
|
||||
try {
|
||||
final res = await widget.event
|
||||
.downloadAndDecryptAttachmentCached(getThumbnail: getThumbnail);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (getThumbnail) {
|
||||
if (mounted) {
|
||||
setState(() => _thumbnail = res);
|
||||
}
|
||||
} else {
|
||||
if (widget.onLoaded != null) {
|
||||
widget.onLoaded();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _file = res);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() => _error = err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget frameBuilder(_, Widget child, int frame, __) {
|
||||
// as servers might return animated gifs as thumbnails and we want them to *not* play
|
||||
// animated, we'll have to store the first frame in a variable and display that instead
|
||||
if (widget.animated) {
|
||||
return child;
|
||||
}
|
||||
if (frame == 0) {
|
||||
_firstFrame = child;
|
||||
}
|
||||
return _firstFrame ?? child;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// add the custom renderers for other mimetypes
|
||||
_contentRenderers['image/svg+xml'] = _ImageBubbleContentRenderer(
|
||||
memory: (Uint8List bytes, String key) => SvgPicture.memory(
|
||||
bytes,
|
||||
key: ValueKey(key),
|
||||
fit: widget.fit,
|
||||
),
|
||||
network: (String url) => SvgPicture.network(
|
||||
url,
|
||||
key: ValueKey(url),
|
||||
placeholderBuilder: (context) => getPlaceholderWidget(),
|
||||
fit: widget.fit,
|
||||
),
|
||||
);
|
||||
_contentRenderers['image/lottie+json'] = _ImageBubbleContentRenderer(
|
||||
memory: (Uint8List bytes, String key) => Lottie.memory(
|
||||
bytes,
|
||||
key: ValueKey(key),
|
||||
fit: widget.fit,
|
||||
errorBuilder: (context, error, stacktrace) =>
|
||||
getErrorWidget(context, error),
|
||||
animate: widget.animated,
|
||||
),
|
||||
network: (String url) => Lottie.network(
|
||||
url,
|
||||
key: ValueKey(url),
|
||||
fit: widget.fit,
|
||||
errorBuilder: (context, error, stacktrace) =>
|
||||
getErrorWidget(context, error),
|
||||
animate: widget.animated,
|
||||
),
|
||||
);
|
||||
|
||||
// add all the custom content renderer mimetypes to the known mimetypes set
|
||||
for (final key in _contentRenderers.keys) {
|
||||
_knownMimetypes.add(key);
|
||||
}
|
||||
|
||||
thumbnailUrl = widget.event
|
||||
.getAttachmentUrl(getThumbnail: true, animated: widget.animated)
|
||||
?.toString();
|
||||
attachmentUrl =
|
||||
widget.event.getAttachmentUrl(animated: widget.animated)?.toString();
|
||||
if (thumbnailUrl == null) {
|
||||
_requestFile(getThumbnail: true);
|
||||
}
|
||||
if (!widget.thumbnailOnly && attachmentUrl == null) {
|
||||
_requestFile();
|
||||
} else {
|
||||
// if the full attachment is cached, we might as well fetch it anyways.
|
||||
// no need to stick with thumbnail only, since we don't do any networking
|
||||
widget.event.isAttachmentCached().then((cached) {
|
||||
if (cached) {
|
||||
_requestFile();
|
||||
}
|
||||
});
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Widget getErrorWidget(BuildContext context, [dynamic error]) {
|
||||
final String filename = widget.event.content.containsKey('filename')
|
||||
? widget.event.content['filename']
|
||||
: widget.event.body;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).scaffoldBackgroundColor,
|
||||
onPrimary: Theme.of(context).textTheme.bodyText1.color,
|
||||
),
|
||||
onPressed: () => widget.event.saveFile(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.download_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
filename,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.event.sizeString != null) Text(widget.event.sizeString),
|
||||
const SizedBox(height: 8),
|
||||
Text((error ?? _error).toString()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getPlaceholderWidget({Widget child}) {
|
||||
Widget blurhash;
|
||||
if (widget.event.infoMap['xyz.amorgan.blurhash'] is String) {
|
||||
final ratio =
|
||||
widget.event.infoMap['w'] is int && widget.event.infoMap['h'] is int
|
||||
? widget.event.infoMap['w'] / widget.event.infoMap['h']
|
||||
: 1.0;
|
||||
var width = 32;
|
||||
var height = 32;
|
||||
if (ratio > 1.0) {
|
||||
height = (width / ratio).round();
|
||||
} else {
|
||||
width = (height * ratio).round();
|
||||
}
|
||||
blurhash = BlurHash(
|
||||
hash: widget.event.infoMap['xyz.amorgan.blurhash'],
|
||||
decodingWidth: width,
|
||||
decodingHeight: height,
|
||||
imageFit: widget.fit,
|
||||
);
|
||||
}
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
if (blurhash != null) blurhash,
|
||||
Center(
|
||||
child:
|
||||
child ?? const CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Build a memory file (e2ee)
|
||||
Widget getMemoryWidget() {
|
||||
final isOriginal = _file != null ||
|
||||
widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) ==
|
||||
widget.event.attachmentMxcUrl;
|
||||
final key = isOriginal
|
||||
? widget.event.attachmentMxcUrl.toString()
|
||||
: widget.event.thumbnailMxcUrl.toString();
|
||||
final mimetype = getMimetype(!isOriginal);
|
||||
if (_contentRenderers.containsKey(mimetype)) {
|
||||
return _contentRenderers[mimetype].memory(_displayFile.bytes, key);
|
||||
} else {
|
||||
return Image.memory(
|
||||
_displayFile.bytes,
|
||||
key: ValueKey(key),
|
||||
fit: widget.fit,
|
||||
errorBuilder: (context, error, stacktrace) {
|
||||
if (widget.event.hasThumbnail && !_requestedThumbnailOnFailure) {
|
||||
_requestedThumbnailOnFailure = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_file = null;
|
||||
_requestFile(getThumbnail: true);
|
||||
});
|
||||
});
|
||||
return getPlaceholderWidget();
|
||||
}
|
||||
return getErrorWidget(context, error);
|
||||
},
|
||||
frameBuilder: frameBuilder,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// build a network file (plaintext)
|
||||
Widget getNetworkWidget() {
|
||||
// For network files we try to utilize server-side thumbnailing as much as possible.
|
||||
// Thus, we do the following logic:
|
||||
// - try to display our URL
|
||||
// - on failure: Attempt to display the in-event thumbnail instead
|
||||
// - on failrue / non-existance: Display button to download or view in-app
|
||||
final mimetype = getMimetype(_requestedThumbnailOnFailure);
|
||||
if (displayUrl == attachmentUrl &&
|
||||
_contentRenderers.containsKey(mimetype)) {
|
||||
return _contentRenderers[mimetype].network(displayUrl);
|
||||
} else {
|
||||
return CachedNetworkImage(
|
||||
// as we change the url on-error we need a key so that the widget actually updates
|
||||
key: ValueKey(displayUrl),
|
||||
imageUrl: displayUrl,
|
||||
placeholder: (context, url) {
|
||||
if (!widget.thumbnailOnly &&
|
||||
displayUrl != thumbnailUrl &&
|
||||
displayUrl == attachmentUrl) {
|
||||
// we have to display the thumbnail while loading
|
||||
return CachedNetworkImage(
|
||||
key: ValueKey(thumbnailUrl),
|
||||
imageUrl: thumbnailUrl,
|
||||
placeholder: (c, u) => getPlaceholderWidget(),
|
||||
imageBuilder: (context, imageProvider) => Image(
|
||||
image: imageProvider,
|
||||
frameBuilder: frameBuilder,
|
||||
fit: widget.fit,
|
||||
),
|
||||
);
|
||||
}
|
||||
return getPlaceholderWidget();
|
||||
},
|
||||
imageBuilder: (context, imageProvider) => Image(
|
||||
image: imageProvider,
|
||||
frameBuilder: frameBuilder,
|
||||
fit: widget.fit,
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
if (widget.event.hasThumbnail && !_requestedThumbnailOnFailure) {
|
||||
// the image failed to load but the event has a thumbnail attached....so we can
|
||||
// try to load this one!
|
||||
_requestedThumbnailOnFailure = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
thumbnailUrl = widget.event
|
||||
.getAttachmentUrl(
|
||||
getThumbnail: true,
|
||||
useThumbnailMxcUrl: true,
|
||||
animated: widget.animated)
|
||||
?.toString();
|
||||
attachmentUrl = widget.event
|
||||
.getAttachmentUrl(
|
||||
useThumbnailMxcUrl: true, animated: widget.animated)
|
||||
?.toString();
|
||||
});
|
||||
});
|
||||
return getPlaceholderWidget();
|
||||
} else if (widget.thumbnailOnly &&
|
||||
displayUrl != attachmentUrl &&
|
||||
_knownMimetypes.contains(mimetype)) {
|
||||
// Okay, the thumbnail failed to load, but we do know how to render the image
|
||||
// ourselves. Let's offer the user a button to view it.
|
||||
return getPlaceholderWidget(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).scaffoldBackgroundColor,
|
||||
onPrimary: Theme.of(context).textTheme.bodyText1.color,
|
||||
),
|
||||
onPressed: () => onTap(context),
|
||||
child: Text(
|
||||
L10n.of(context).tapToShowImage,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (widget.event.sizeString != null)
|
||||
Text(widget.event.sizeString),
|
||||
],
|
||||
));
|
||||
}
|
||||
return getErrorWidget(context, error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget content;
|
||||
String key;
|
||||
if (_error != null) {
|
||||
content = getErrorWidget(context);
|
||||
key = 'error';
|
||||
} else if (_displayFile != null) {
|
||||
content = getMemoryWidget();
|
||||
key = 'memory-' + (content.key as ValueKey).value;
|
||||
} else if (displayUrl != null) {
|
||||
content = getNetworkWidget();
|
||||
key = 'network-' + (content.key as ValueKey).value;
|
||||
} else {
|
||||
content = getPlaceholderWidget();
|
||||
key = 'placeholder';
|
||||
}
|
||||
if (widget.maxSize) {
|
||||
content = AspectRatio(
|
||||
aspectRatio: widget.width / widget.height,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
child: InkWell(
|
||||
onTap: () => onTap(context),
|
||||
child: Hero(
|
||||
tag: widget.event.eventId,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
child: Container(
|
||||
key: ValueKey(key),
|
||||
constraints: widget.maxSize
|
||||
? BoxConstraints.loose(Size(
|
||||
widget.width,
|
||||
widget.height,
|
||||
))
|
||||
: null,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onTap(BuildContext context) {
|
||||
if (widget.onTap != null) {
|
||||
widget.onTap();
|
||||
return;
|
||||
}
|
||||
if (!widget.tapToView) return;
|
||||
showDialog(
|
||||
context: Matrix.of(context).navigatorContext,
|
||||
useRootNavigator: false,
|
||||
builder: (_) => ImageViewer(widget.event, onLoaded: () {
|
||||
// If the original file didn't load yet, we want to do that now.
|
||||
// This is so that the original file displays after going on the image viewer,
|
||||
// waiting for it to load, and then hitting back. This ensures that we always
|
||||
// display the best image available, with requiring as little network as possible
|
||||
if (_file == null) {
|
||||
widget.event.isAttachmentCached().then((cached) {
|
||||
if (cached) {
|
||||
_requestFile();
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageBubbleContentRenderer {
|
||||
final Widget Function(Uint8List, String) memory;
|
||||
final Widget Function(String) network;
|
||||
|
||||
_ImageBubbleContentRenderer({this.memory, this.network});
|
||||
}
|
||||
59
lib/pages/chat/events/map_bubble.dart
Normal file
59
lib/pages/chat/events/map_bubble.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MapBubble extends StatelessWidget {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double zoom;
|
||||
final double width;
|
||||
final double height;
|
||||
final double radius;
|
||||
const MapBubble({
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.zoom = 14.0,
|
||||
this.width = 400,
|
||||
this.height = 400,
|
||||
this.radius = 10.0,
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.loose(Size(width, height)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: width / height,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
center: LatLng(latitude, longitude),
|
||||
zoom: zoom,
|
||||
),
|
||||
layers: [
|
||||
TileLayerOptions(
|
||||
urlTemplate:
|
||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
),
|
||||
MarkerLayerOptions(
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(latitude, longitude),
|
||||
builder: (context) => const Icon(
|
||||
Icons.location_pin,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
301
lib/pages/chat/events/message.dart
Normal file
301
lib/pages/chat/events/message.dart
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/utils/date_time_extension.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart';
|
||||
import 'package:fluffychat/utils/string_color.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'message_content.dart';
|
||||
import 'message_reactions.dart';
|
||||
import 'reply_content.dart';
|
||||
import 'state_message.dart';
|
||||
import 'verification_request_content.dart';
|
||||
|
||||
class Message extends StatelessWidget {
|
||||
final Event event;
|
||||
final Event nextEvent;
|
||||
final void Function(Event) onSelect;
|
||||
final void Function(Event) onAvatarTab;
|
||||
final void Function(String) scrollToEventId;
|
||||
final void Function(String) unfold;
|
||||
final bool longPressSelect;
|
||||
final bool selected;
|
||||
final Timeline timeline;
|
||||
|
||||
const Message(this.event,
|
||||
{this.nextEvent,
|
||||
this.longPressSelect,
|
||||
this.onSelect,
|
||||
this.onAvatarTab,
|
||||
this.scrollToEventId,
|
||||
@required this.unfold,
|
||||
this.selected,
|
||||
this.timeline,
|
||||
Key key})
|
||||
: super(key: key);
|
||||
|
||||
/// Indicates wheither the user may use a mouse instead
|
||||
/// of touchscreen.
|
||||
static bool useMouse = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (![EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
|
||||
.contains(event.type)) {
|
||||
return StateMessage(event, unfold: unfold);
|
||||
}
|
||||
|
||||
if (event.type == EventTypes.Message &&
|
||||
event.messageType == EventTypes.KeyVerificationRequest) {
|
||||
return VerificationRequestContent(event: event, timeline: timeline);
|
||||
}
|
||||
|
||||
final client = Matrix.of(context).client;
|
||||
final ownMessage = event.senderId == client.userID;
|
||||
final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft;
|
||||
var color = Theme.of(context).secondaryHeaderColor;
|
||||
final sameSender = nextEvent != null &&
|
||||
[EventTypes.Message, EventTypes.Sticker].contains(nextEvent.type)
|
||||
? nextEvent.sender.id == event.sender.id
|
||||
: false;
|
||||
var textColor = ownMessage
|
||||
? Colors.white
|
||||
: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
final rowMainAxisAlignment =
|
||||
ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start;
|
||||
|
||||
final displayEvent = event.getDisplayEvent(timeline);
|
||||
|
||||
if (event.showThumbnail) {
|
||||
color = Colors.transparent;
|
||||
textColor = Theme.of(context).textTheme.bodyText2.color;
|
||||
} else if (ownMessage) {
|
||||
color = displayEvent.status.isError
|
||||
? Colors.redAccent
|
||||
: Theme.of(context).primaryColor;
|
||||
}
|
||||
|
||||
final rowChildren = <Widget>[
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Material(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: InkWell(
|
||||
onHover: (b) => useMouse = true,
|
||||
onTap: !useMouse && longPressSelect
|
||||
? () => null
|
||||
: () => onSelect(event),
|
||||
onLongPress: !longPressSelect ? null : () => onSelect(event),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.columnWidth * 1.5),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (event.relationshipType == RelationshipTypes.reply)
|
||||
FutureBuilder<Event>(
|
||||
future: event.getReplyEvent(timeline),
|
||||
builder: (BuildContext context, snapshot) {
|
||||
final replyEvent = snapshot.hasData
|
||||
? snapshot.data
|
||||
: Event(
|
||||
eventId: event.relationshipEventId,
|
||||
content: {
|
||||
'msgtype': 'm.text',
|
||||
'body': '...'
|
||||
},
|
||||
senderId: event.senderId,
|
||||
type: 'm.room.message',
|
||||
room: event.room,
|
||||
status: EventStatus.sent,
|
||||
originServerTs: DateTime.now(),
|
||||
);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (scrollToEventId != null) {
|
||||
scrollToEventId(replyEvent.eventId);
|
||||
}
|
||||
},
|
||||
child: AbsorbPointer(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 4.0),
|
||||
child: ReplyContent(replyEvent,
|
||||
lightText: ownMessage,
|
||||
timeline: timeline),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
MessageContent(
|
||||
displayEvent,
|
||||
textColor: textColor,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Opacity(
|
||||
opacity: 0,
|
||||
child: _MetaRow(
|
||||
event, // meta information should be from the unedited event
|
||||
ownMessage,
|
||||
textColor,
|
||||
timeline,
|
||||
displayEvent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: ownMessage ? 0 : null,
|
||||
left: !ownMessage ? 0 : null,
|
||||
child: _MetaRow(
|
||||
event,
|
||||
ownMessage,
|
||||
textColor,
|
||||
timeline,
|
||||
displayEvent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
final avatarOrSizedBox = sameSender
|
||||
? const SizedBox(width: Avatar.defaultSize)
|
||||
: Avatar(
|
||||
event.sender.avatarUrl,
|
||||
event.sender.calcDisplayname(),
|
||||
onTap: () => onAvatarTab(event),
|
||||
);
|
||||
if (ownMessage) {
|
||||
rowChildren.add(avatarOrSizedBox);
|
||||
} else {
|
||||
rowChildren.insert(0, avatarOrSizedBox);
|
||||
}
|
||||
final row = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: rowMainAxisAlignment,
|
||||
children: rowChildren,
|
||||
);
|
||||
Widget container;
|
||||
if (event.hasAggregatedEvents(timeline, RelationshipTypes.reaction)) {
|
||||
container = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment:
|
||||
ownMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
row,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 4.0,
|
||||
left: (ownMessage ? 0 : Avatar.defaultSize) + 12.0,
|
||||
right: (ownMessage ? Avatar.defaultSize : 0) + 12.0,
|
||||
),
|
||||
child: MessageReactions(event, timeline),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
container = row;
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
color: selected
|
||||
? Theme.of(context).primaryColor.withAlpha(100)
|
||||
: Theme.of(context).primaryColor.withAlpha(0),
|
||||
constraints:
|
||||
const BoxConstraints(maxWidth: FluffyThemes.columnWidth * 2.5),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, bottom: 4.0, top: 4.0),
|
||||
child: container,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaRow extends StatelessWidget {
|
||||
final Event event;
|
||||
final bool ownMessage;
|
||||
final Color color;
|
||||
final Timeline timeline;
|
||||
final Event displayEvent;
|
||||
|
||||
const _MetaRow(
|
||||
this.event, this.ownMessage, this.color, this.timeline, this.displayEvent,
|
||||
{Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayname = event.sender.calcDisplayname();
|
||||
final showDisplayname =
|
||||
!ownMessage && event.senderId != event.room.directChatMatrixID;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (showDisplayname)
|
||||
Text(
|
||||
displayname,
|
||||
style: TextStyle(
|
||||
fontSize: 10 * AppConfig.fontSizeFactor,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: (Theme.of(context).brightness == Brightness.light
|
||||
? displayname.darkColor
|
||||
: displayname.lightColor)
|
||||
.withAlpha(200),
|
||||
),
|
||||
),
|
||||
if (showDisplayname) const SizedBox(width: 4),
|
||||
Text(
|
||||
event.originServerTs.localizedTime(context),
|
||||
style: TextStyle(
|
||||
color: color.withAlpha(180),
|
||||
fontSize: 10 * AppConfig.fontSizeFactor,
|
||||
),
|
||||
),
|
||||
if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2.0),
|
||||
child: Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 12 * AppConfig.fontSizeFactor,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
if (ownMessage) const SizedBox(width: 2),
|
||||
if (ownMessage)
|
||||
Icon(
|
||||
displayEvent.statusIcon,
|
||||
size: 14 * AppConfig.fontSizeFactor,
|
||||
color: color,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
278
lib/pages/chat/events/message_content.dart
Normal file
278
lib/pages/chat/events/message_content.dart
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
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/encryption/utils/key_verification.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix_link_text/link_text.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:fluffychat/pages/key_verification/key_verification_dialog.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../pages/video_viewer/video_viewer.dart';
|
||||
import '../../../utils/platform_infos.dart';
|
||||
import '../../../utils/url_launcher.dart';
|
||||
import 'audio_player.dart';
|
||||
import 'html_message.dart';
|
||||
import 'image_bubble.dart';
|
||||
import 'map_bubble.dart';
|
||||
import 'message_download_content.dart';
|
||||
import 'sticker.dart';
|
||||
|
||||
class MessageContent extends StatelessWidget {
|
||||
final Event event;
|
||||
final Color textColor;
|
||||
|
||||
const MessageContent(this.event, {Key key, this.textColor}) : super(key: key);
|
||||
|
||||
void _verifyOrRequestKey(BuildContext context) async {
|
||||
if (event.content['can_request_session'] != true) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
event.type == EventTypes.Encrypted
|
||||
? L10n.of(context).needPantalaimonWarning
|
||||
: event.getLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
),
|
||||
)));
|
||||
return;
|
||||
}
|
||||
final client = Matrix.of(context).client;
|
||||
if (client.isUnknownSession && client.encryption.crossSigning.enabled) {
|
||||
final req =
|
||||
await client.userDeviceKeys[client.userID].startVerification();
|
||||
req.onUpdate = () async {
|
||||
if (req.state == KeyVerificationState.done) {
|
||||
for (var i = 0; i < 12; i++) {
|
||||
if (await client.encryption.keyManager.isCached()) {
|
||||
break;
|
||||
}
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
final timeline = await event.room.getTimeline();
|
||||
timeline.requestKeys();
|
||||
timeline.cancelSubscriptions();
|
||||
}
|
||||
};
|
||||
await KeyVerificationDialog(request: req).show(context);
|
||||
} else {
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => event.requestKey(),
|
||||
);
|
||||
if (success.error == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(L10n.of(context).requestToReadOlderMessages)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fontSize =
|
||||
DefaultTextStyle.of(context).style.fontSize * AppConfig.fontSizeFactor;
|
||||
switch (event.type) {
|
||||
case EventTypes.Message:
|
||||
case EventTypes.Encrypted:
|
||||
case EventTypes.Sticker:
|
||||
switch (event.messageType) {
|
||||
case MessageTypes.Image:
|
||||
if (event.showThumbnail) {
|
||||
return ImageBubble(
|
||||
event,
|
||||
width: 400,
|
||||
height: 300,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
return MessageDownloadContent(event, textColor);
|
||||
case MessageTypes.Sticker:
|
||||
if (event.showThumbnail) {
|
||||
return Sticker(event);
|
||||
}
|
||||
return MessageDownloadContent(event, textColor);
|
||||
case MessageTypes.Audio:
|
||||
if (PlatformInfos.isMobile) {
|
||||
return AudioPlayerWidget(
|
||||
event,
|
||||
color: textColor,
|
||||
);
|
||||
}
|
||||
return MessageDownloadContent(event, textColor);
|
||||
case MessageTypes.Video:
|
||||
if (event.showThumbnail &&
|
||||
(PlatformInfos.isMobile || PlatformInfos.isWeb)) {
|
||||
return InkWell(
|
||||
onTap: () => showDialog(
|
||||
context: Matrix.of(context).navigatorContext,
|
||||
useRootNavigator: false,
|
||||
builder: (_) => VideoViewer(event),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: <Widget>[
|
||||
ImageBubble(
|
||||
event,
|
||||
width: 400,
|
||||
height: 300,
|
||||
fit: BoxFit.cover,
|
||||
tapToView: false,
|
||||
),
|
||||
const Icon(Icons.play_circle_outline,
|
||||
size: 200, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return MessageDownloadContent(event, textColor);
|
||||
case MessageTypes.File:
|
||||
return MessageDownloadContent(event, textColor);
|
||||
|
||||
case MessageTypes.Text:
|
||||
case MessageTypes.Notice:
|
||||
case MessageTypes.Emote:
|
||||
if (AppConfig.renderHtml &&
|
||||
!event.redacted &&
|
||||
event.isRichMessage) {
|
||||
var html = event.formattedText;
|
||||
if (event.messageType == MessageTypes.Emote) {
|
||||
html = '* $html';
|
||||
}
|
||||
final bigEmotes = event.onlyEmotes &&
|
||||
event.numberEmotes > 0 &&
|
||||
event.numberEmotes <= 10;
|
||||
return HtmlMessage(
|
||||
html: html,
|
||||
defaultTextStyle: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: bigEmotes ? fontSize * 3 : fontSize,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: textColor.withAlpha(150),
|
||||
fontSize: bigEmotes ? fontSize * 3 : fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
room: event.room,
|
||||
emoteSize: bigEmotes ? fontSize * 3 : fontSize * 1.5,
|
||||
);
|
||||
}
|
||||
// else we fall through to the normal message rendering
|
||||
continue textmessage;
|
||||
case MessageTypes.BadEncrypted:
|
||||
case EventTypes.Encrypted:
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).scaffoldBackgroundColor,
|
||||
onPrimary: Theme.of(context).textTheme.bodyText1.color,
|
||||
),
|
||||
onPressed: () => _verifyOrRequestKey(context),
|
||||
icon: const Icon(Icons.lock_outline),
|
||||
label: Text(L10n.of(context).encrypted),
|
||||
);
|
||||
case MessageTypes.Location:
|
||||
final geoUri =
|
||||
Uri.tryParse(event.content.tryGet<String>('geo_uri'));
|
||||
if (geoUri != null &&
|
||||
geoUri.scheme == 'geo' &&
|
||||
geoUri.path != null) {
|
||||
final latlong = geoUri.path
|
||||
.split(';')
|
||||
.first
|
||||
.split(',')
|
||||
.map((s) => double.tryParse(s))
|
||||
.toList();
|
||||
if (latlong.length == 2 &&
|
||||
latlong.first != null &&
|
||||
latlong.last != null) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapBubble(
|
||||
latitude: latlong.first,
|
||||
longitude: latlong.last,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
OutlinedButton.icon(
|
||||
icon: Icon(Icons.location_on_outlined, color: textColor),
|
||||
onPressed:
|
||||
UrlLauncher(context, geoUri.toString()).launchUrl,
|
||||
label: Text(
|
||||
L10n.of(context).openInMaps,
|
||||
style: TextStyle(color: textColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
continue textmessage;
|
||||
case MessageTypes.None:
|
||||
textmessage:
|
||||
default:
|
||||
if (event.content['msgtype'] == Matrix.callNamespace) {
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).scaffoldBackgroundColor,
|
||||
onPrimary: Theme.of(context).textTheme.bodyText1.color,
|
||||
),
|
||||
onPressed: () => launch(event.body),
|
||||
icon: const Icon(Icons.phone_outlined, color: Colors.green),
|
||||
label: Text(L10n.of(context).videoCall),
|
||||
);
|
||||
}
|
||||
if (event.redacted) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.delete_forever_outlined, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
event.getLocalizedBody(MatrixLocals(L10n.of(context)),
|
||||
hideReply: true),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
decorationThickness: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final bigEmotes = event.onlyEmotes &&
|
||||
event.numberEmotes > 0 &&
|
||||
event.numberEmotes <= 10;
|
||||
return LinkText(
|
||||
text: event.getLocalizedBody(MatrixLocals(L10n.of(context)),
|
||||
hideReply: true),
|
||||
textStyle: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: bigEmotes ? fontSize * 3 : fontSize,
|
||||
decoration: event.redacted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: textColor.withAlpha(150),
|
||||
fontSize: bigEmotes ? fontSize * 3 : fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return Text(
|
||||
L10n.of(context)
|
||||
.userSentUnknownEvent(event.sender.calcDisplayname(), event.type),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
decoration: event.redacted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(); // else flutter analyze complains
|
||||
}
|
||||
}
|
||||
55
lib/pages/chat/events/message_download_content.dart
Normal file
55
lib/pages/chat/events/message_download_content.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart';
|
||||
|
||||
class MessageDownloadContent extends StatelessWidget {
|
||||
final Event event;
|
||||
final Color textColor;
|
||||
|
||||
const MessageDownloadContent(this.event, this.textColor, {Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String filename = event.content.containsKey('filename')
|
||||
? event.content['filename']
|
||||
: event.body;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).scaffoldBackgroundColor,
|
||||
onPrimary: Theme.of(context).textTheme.bodyText1.color,
|
||||
),
|
||||
onPressed: () => event.saveFile(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.download_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
filename,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (event.sizeString != null)
|
||||
Text(
|
||||
event.sizeString,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
229
lib/pages/chat/events/message_reactions.dart
Normal file
229
lib/pages/chat/events/message_reactions.dart
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:characters/characters.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class MessageReactions extends StatelessWidget {
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
|
||||
const MessageReactions(this.event, this.timeline, {Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allReactionEvents =
|
||||
event.aggregatedEvents(timeline, RelationshipTypes.reaction);
|
||||
final reactionMap = <String, _ReactionEntry>{};
|
||||
final client = Matrix.of(context).client;
|
||||
|
||||
for (final e in allReactionEvents) {
|
||||
if (e.content['m.relates_to'].containsKey('key')) {
|
||||
final key = e.content['m.relates_to']['key'];
|
||||
if (!reactionMap.containsKey(key)) {
|
||||
reactionMap[key] = _ReactionEntry(
|
||||
key: key,
|
||||
count: 0,
|
||||
reacted: false,
|
||||
reactors: [],
|
||||
);
|
||||
}
|
||||
reactionMap[key].count++;
|
||||
reactionMap[key].reactors.add(e.sender);
|
||||
reactionMap[key].reacted |= e.senderId == e.room.client.userID;
|
||||
}
|
||||
}
|
||||
final reactionList = reactionMap.values.toList();
|
||||
reactionList.sort((a, b) => b.count - a.count > 0 ? 1 : -1);
|
||||
return Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 4.0,
|
||||
children: reactionList
|
||||
.map(
|
||||
(r) => _Reaction(
|
||||
reactionKey: r.key,
|
||||
count: r.count,
|
||||
reacted: r.reacted,
|
||||
onTap: () {
|
||||
if (r.reacted) {
|
||||
final evt = allReactionEvents.firstWhere(
|
||||
(e) =>
|
||||
e.senderId == e.room.client.userID &&
|
||||
e.content['m.relates_to']['key'] == r.key,
|
||||
orElse: () => null);
|
||||
if (evt != null) {
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => evt.redactEvent(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () =>
|
||||
event.room.sendReaction(event.eventId, r.key));
|
||||
}
|
||||
},
|
||||
onLongPress: () async => await _AdaptableReactorsDialog(
|
||||
client: client,
|
||||
reactionEntry: r,
|
||||
).show(context),
|
||||
),
|
||||
)
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
class _Reaction extends StatelessWidget {
|
||||
final String reactionKey;
|
||||
final int count;
|
||||
final bool reacted;
|
||||
final void Function() onTap;
|
||||
final void Function() onLongPress;
|
||||
|
||||
const _Reaction({
|
||||
this.reactionKey,
|
||||
this.count,
|
||||
this.reacted,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderColor = reacted
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).secondaryHeaderColor;
|
||||
final textColor = Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
final color = Theme.of(context).scaffoldBackgroundColor;
|
||||
final fontSize = DefaultTextStyle.of(context).style.fontSize;
|
||||
final padding = fontSize / 5;
|
||||
Widget content;
|
||||
if (reactionKey.startsWith('mxc://')) {
|
||||
final src = Uri.parse(reactionKey)?.getThumbnail(
|
||||
Matrix.of(context).client,
|
||||
width: 9999,
|
||||
height: fontSize * MediaQuery.of(context).devicePixelRatio,
|
||||
method: ThumbnailMethod.scale,
|
||||
);
|
||||
content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
CachedNetworkImage(
|
||||
imageUrl: src.toString(),
|
||||
height: fontSize,
|
||||
),
|
||||
Container(width: 4),
|
||||
Text(count.toString(),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: DefaultTextStyle.of(context).style.fontSize,
|
||||
)),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
var renderKey = Characters(reactionKey);
|
||||
if (renderKey.length > 10) {
|
||||
renderKey = renderKey.getRange(0, 9) + Characters('…');
|
||||
}
|
||||
content = Text('$renderKey $count',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: DefaultTextStyle.of(context).style.fontSize,
|
||||
));
|
||||
}
|
||||
return InkWell(
|
||||
onTap: () => onTap != null ? onTap() : null,
|
||||
onLongPress: () => onLongPress != null ? onLongPress() : null,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
border: Border.all(
|
||||
width: 1,
|
||||
color: borderColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionEntry {
|
||||
String key;
|
||||
int count;
|
||||
bool reacted;
|
||||
List<User> reactors;
|
||||
|
||||
_ReactionEntry({this.key, this.count, this.reacted, this.reactors});
|
||||
}
|
||||
|
||||
class _AdaptableReactorsDialog extends StatelessWidget {
|
||||
final Client client;
|
||||
final _ReactionEntry reactionEntry;
|
||||
|
||||
const _AdaptableReactorsDialog({
|
||||
Key key,
|
||||
this.client,
|
||||
this.reactionEntry,
|
||||
}) : super(key: key);
|
||||
|
||||
Future<bool> show(BuildContext context) => PlatformInfos.isCupertinoStyle
|
||||
? showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (context) => this,
|
||||
barrierDismissible: true,
|
||||
useRootNavigator: false,
|
||||
)
|
||||
: showDialog(
|
||||
context: context,
|
||||
builder: (context) => this,
|
||||
barrierDismissible: true,
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
alignment: WrapAlignment.center,
|
||||
children: <Widget>[
|
||||
for (var reactor in reactionEntry.reactors)
|
||||
Chip(
|
||||
avatar: Avatar(
|
||||
reactor.avatarUrl,
|
||||
reactor.displayName,
|
||||
client: client,
|
||||
),
|
||||
label: Text(reactor.displayName),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final title = Center(child: Text(reactionEntry.key));
|
||||
|
||||
return PlatformInfos.isCupertinoStyle
|
||||
? CupertinoAlertDialog(
|
||||
title: title,
|
||||
content: body,
|
||||
)
|
||||
: AlertDialog(
|
||||
title: title,
|
||||
content: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/pages/chat/events/reply_content.dart
Normal file
102
lib/pages/chat/events/reply_content.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'html_message.dart';
|
||||
|
||||
class ReplyContent extends StatelessWidget {
|
||||
final Event replyEvent;
|
||||
final bool lightText;
|
||||
final Timeline timeline;
|
||||
|
||||
const ReplyContent(this.replyEvent,
|
||||
{this.lightText = false, Key key, this.timeline})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget replyBody;
|
||||
final displayEvent = replyEvent != null && timeline != null
|
||||
? replyEvent.getDisplayEvent(timeline)
|
||||
: replyEvent;
|
||||
final fontSize =
|
||||
DefaultTextStyle.of(context).style.fontSize * AppConfig.fontSizeFactor;
|
||||
if (displayEvent != null &&
|
||||
AppConfig.renderHtml &&
|
||||
[EventTypes.Message, EventTypes.Encrypted]
|
||||
.contains(displayEvent.type) &&
|
||||
[MessageTypes.Text, MessageTypes.Notice, MessageTypes.Emote]
|
||||
.contains(displayEvent.messageType) &&
|
||||
!displayEvent.redacted &&
|
||||
displayEvent.content['format'] == 'org.matrix.custom.html' &&
|
||||
displayEvent.content['formatted_body'] is String) {
|
||||
String html = displayEvent.content['formatted_body'];
|
||||
if (displayEvent.messageType == MessageTypes.Emote) {
|
||||
html = '* $html';
|
||||
}
|
||||
replyBody = HtmlMessage(
|
||||
html: html,
|
||||
defaultTextStyle: TextStyle(
|
||||
color: lightText
|
||||
? Colors.white
|
||||
: Theme.of(context).textTheme.bodyText2.color,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
maxLines: 1,
|
||||
room: displayEvent.room,
|
||||
emoteSize: fontSize * 1.5,
|
||||
);
|
||||
} else {
|
||||
replyBody = Text(
|
||||
displayEvent?.getLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
withSenderNamePrefix: false,
|
||||
hideReply: true,
|
||||
) ??
|
||||
'',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: lightText
|
||||
? Colors.white
|
||||
: Theme.of(context).textTheme.bodyText2.color,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 3,
|
||||
height: fontSize * 2 + 6,
|
||||
color: lightText ? Colors.white : Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
(displayEvent?.sender?.calcDisplayname() ?? '') + ':',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
lightText ? Colors.white : Theme.of(context).primaryColor,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
),
|
||||
replyBody,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/pages/chat/events/state_message.dart
Normal file
68
lib/pages/chat/events/state_message.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class StateMessage extends StatelessWidget {
|
||||
final Event event;
|
||||
final void Function(String) unfold;
|
||||
const StateMessage(this.event, {@required this.unfold, Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (event.unsigned['im.fluffychat.collapsed_state_event'] == true) {
|
||||
return Container();
|
||||
}
|
||||
final int counter =
|
||||
event.unsigned['im.fluffychat.collapsed_state_event_count'] ?? 0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Center(
|
||||
child: InkWell(
|
||||
onTap: counter != 0 ? () => unfold(event.eventId) : null,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
event.getLocalizedBody(MatrixLocals(L10n.of(context))),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: Theme.of(context).textTheme.bodyText1.fontSize *
|
||||
AppConfig.fontSizeFactor,
|
||||
color: Theme.of(context).textTheme.bodyText2.color,
|
||||
decoration:
|
||||
event.redacted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
if (counter != 0)
|
||||
Text(
|
||||
L10n.of(context).moreEvents(counter),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/pages/chat/events/sticker.dart
Normal file
53
lib/pages/chat/events/sticker.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import 'image_bubble.dart';
|
||||
|
||||
class Sticker extends StatefulWidget {
|
||||
final Event event;
|
||||
|
||||
const Sticker(this.event, {Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
_StickerState createState() => _StickerState();
|
||||
}
|
||||
|
||||
class _StickerState extends State<Sticker> {
|
||||
bool animated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// stickers should default to a ratio of 1:1
|
||||
var ratio = 1.0;
|
||||
// if a width and a height is specified for stickers, use those!
|
||||
if (widget.event.infoMap['w'] is int && widget.event.infoMap['h'] is int) {
|
||||
ratio = widget.event.infoMap['w'] / widget.event.infoMap['h'];
|
||||
// make sure the ratio is within 0.9 - 2.0
|
||||
if (ratio > 2.0) {
|
||||
ratio = 2.0;
|
||||
}
|
||||
if (ratio < 0.9) {
|
||||
ratio = 0.9;
|
||||
}
|
||||
}
|
||||
return ImageBubble(
|
||||
widget.event,
|
||||
width: 400,
|
||||
height: 400 / ratio,
|
||||
fit: ratio <= 1.0 ? BoxFit.contain : BoxFit.cover,
|
||||
onTap: () {
|
||||
setState(() => animated = true);
|
||||
showOkAlertDialog(
|
||||
context: context,
|
||||
message: widget.event.body,
|
||||
okLabel: L10n.of(context).ok,
|
||||
);
|
||||
},
|
||||
animated: animated ?? AppConfig.autoplayImages,
|
||||
);
|
||||
}
|
||||
}
|
||||
63
lib/pages/chat/events/verification_request_content.dart
Normal file
63
lib/pages/chat/events/verification_request_content.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class VerificationRequestContent extends StatelessWidget {
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
|
||||
const VerificationRequestContent({this.event, this.timeline, Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final events = event.aggregatedEvents(timeline, 'm.reference');
|
||||
final done = events.where((e) => e.type == EventTypes.KeyVerificationDone);
|
||||
final start =
|
||||
events.where((e) => e.type == EventTypes.KeyVerificationStart);
|
||||
final cancel =
|
||||
events.where((e) => e.type == EventTypes.KeyVerificationCancel);
|
||||
final fullyDone = done.length >= 2;
|
||||
final started = start.isNotEmpty;
|
||||
final canceled = cancel.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
color: Theme.of(context).backgroundColor,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(Icons.lock_outlined,
|
||||
color: canceled
|
||||
? Colors.red
|
||||
: (fullyDone ? Colors.green : Colors.grey)),
|
||||
const SizedBox(width: 8),
|
||||
Text(canceled
|
||||
? 'Error ${cancel.first.content.tryGet<String>('code')}: ${cancel.first.content.tryGet<String>('reason')}'
|
||||
: (fullyDone
|
||||
? L10n.of(context).verifySuccess
|
||||
: (started
|
||||
? L10n.of(context).loadingPleaseWait
|
||||
: L10n.of(context).newVerificationRequest)))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
445
lib/pages/chat/input_bar.dart
Normal file
445
lib/pages/chat/input_bar.dart
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:slugify/slugify.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import '../../widgets/avatar.dart';
|
||||
import '../../widgets/matrix.dart';
|
||||
|
||||
class InputBar extends StatelessWidget {
|
||||
final Room room;
|
||||
final int minLines;
|
||||
final int maxLines;
|
||||
final TextInputType keyboardType;
|
||||
final TextInputAction textInputAction;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
final FocusNode focusNode;
|
||||
final TextEditingController controller;
|
||||
final InputDecoration decoration;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool autofocus;
|
||||
|
||||
const InputBar({
|
||||
this.room,
|
||||
this.minLines,
|
||||
this.maxLines,
|
||||
this.keyboardType,
|
||||
this.onSubmitted,
|
||||
this.focusNode,
|
||||
this.controller,
|
||||
this.decoration,
|
||||
this.onChanged,
|
||||
this.autofocus,
|
||||
this.textInputAction,
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
List<Map<String, String>> getSuggestions(String text) {
|
||||
if (controller.selection.baseOffset != controller.selection.extentOffset ||
|
||||
controller.selection.baseOffset < 0) {
|
||||
return []; // no entries if there is selected text
|
||||
}
|
||||
final searchText =
|
||||
controller.text.substring(0, controller.selection.baseOffset);
|
||||
final ret = <Map<String, String>>[];
|
||||
const maxResults = 30;
|
||||
|
||||
final commandMatch = RegExp(r'^\/([\w]*)$').firstMatch(searchText);
|
||||
if (commandMatch != null) {
|
||||
final commandSearch = commandMatch[1].toLowerCase();
|
||||
for (final command in room.client.commands.keys) {
|
||||
if (command.contains(commandSearch)) {
|
||||
ret.add({
|
||||
'type': 'command',
|
||||
'name': command,
|
||||
});
|
||||
}
|
||||
|
||||
if (ret.length > maxResults) return ret;
|
||||
}
|
||||
}
|
||||
final emojiMatch =
|
||||
RegExp(r'(?:\s|^):(?:([-\w]+)~)?([-\w]+)$').firstMatch(searchText);
|
||||
if (emojiMatch != null) {
|
||||
final packSearch = emojiMatch[1];
|
||||
final emoteSearch = emojiMatch[2].toLowerCase();
|
||||
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
|
||||
if (packSearch == null || packSearch.isEmpty) {
|
||||
for (final pack in emotePacks.entries) {
|
||||
for (final emote in pack.value.images.entries) {
|
||||
if (emote.key.toLowerCase().contains(emoteSearch)) {
|
||||
ret.add({
|
||||
'type': 'emote',
|
||||
'name': emote.key,
|
||||
'pack': pack.key,
|
||||
'pack_avatar_url': pack.value.pack.avatarUrl?.toString(),
|
||||
'pack_display_name': pack.value.pack.displayName ?? pack.key,
|
||||
'mxc': emote.value.url.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (emotePacks[packSearch] != null) {
|
||||
for (final emote in emotePacks[packSearch].images.entries) {
|
||||
if (emote.key.toLowerCase().contains(emoteSearch)) {
|
||||
ret.add({
|
||||
'type': 'emote',
|
||||
'name': emote.key,
|
||||
'pack': packSearch,
|
||||
'pack_avatar_url':
|
||||
emotePacks[packSearch].pack.avatarUrl?.toString(),
|
||||
'pack_display_name':
|
||||
emotePacks[packSearch].pack.displayName ?? packSearch,
|
||||
'mxc': emote.value.url.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final userMatch = RegExp(r'(?:\s|^)@([-\w]+)$').firstMatch(searchText);
|
||||
if (userMatch != null) {
|
||||
final userSearch = userMatch[1].toLowerCase();
|
||||
for (final user in room.getParticipants()) {
|
||||
if ((user.displayName != null &&
|
||||
(user.displayName.toLowerCase().contains(userSearch) ||
|
||||
slugify(user.displayName.toLowerCase())
|
||||
.contains(userSearch))) ||
|
||||
user.id.split(':')[0].toLowerCase().contains(userSearch)) {
|
||||
ret.add({
|
||||
'type': 'user',
|
||||
'mxid': user.id,
|
||||
'mention': user.mention,
|
||||
'displayname': user.displayName,
|
||||
'avatar_url': user.avatarUrl?.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
final roomMatch = RegExp(r'(?:\s|^)#([-\w]+)$').firstMatch(searchText);
|
||||
if (roomMatch != null) {
|
||||
final roomSearch = roomMatch[1].toLowerCase();
|
||||
for (final r in room.client.rooms) {
|
||||
if (r.getState(EventTypes.RoomTombstone) != null) {
|
||||
continue; // we don't care about tombstoned rooms
|
||||
}
|
||||
final state = r.getState(EventTypes.RoomCanonicalAlias);
|
||||
if ((state != null &&
|
||||
((state.content['alias'] is String &&
|
||||
state.content['alias']
|
||||
.split(':')[0]
|
||||
.toLowerCase()
|
||||
.contains(roomSearch)) ||
|
||||
(state.content['alt_aliases'] is List &&
|
||||
state.content['alt_aliases'].any((l) =>
|
||||
l is String &&
|
||||
l
|
||||
.split(':')[0]
|
||||
.toLowerCase()
|
||||
.contains(roomSearch))))) ||
|
||||
(r.name != null && r.name.toLowerCase().contains(roomSearch))) {
|
||||
ret.add({
|
||||
'type': 'room',
|
||||
'mxid': (r.canonicalAlias != null && r.canonicalAlias.isNotEmpty)
|
||||
? r.canonicalAlias
|
||||
: r.id,
|
||||
'displayname': r.displayname,
|
||||
'avatar_url': r.avatar?.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
String _commandHint(L10n l10n, String command) {
|
||||
switch (command) {
|
||||
case 'send':
|
||||
return l10n.commandHintSend;
|
||||
case 'me':
|
||||
return l10n.commandHintMe;
|
||||
case 'plain':
|
||||
return l10n.commandHintPlain;
|
||||
case 'html':
|
||||
return l10n.commandHintHtml;
|
||||
case 'react':
|
||||
return l10n.commandHintReact;
|
||||
case 'join':
|
||||
return l10n.commandHintJoin;
|
||||
case 'leave':
|
||||
return l10n.commandHintLeave;
|
||||
case 'op':
|
||||
return l10n.commandHintOp;
|
||||
case 'kick':
|
||||
return l10n.commandHintKick;
|
||||
case 'ban':
|
||||
return l10n.commandHintBan;
|
||||
case 'unban':
|
||||
return l10n.commandHintUnBan;
|
||||
case 'invite':
|
||||
return l10n.commandHintInvite;
|
||||
case 'myroomnick':
|
||||
return l10n.commandHintMyRoomNick;
|
||||
case 'myroomavatar':
|
||||
return l10n.commandHintMyRoomAvatar;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildSuggestion(
|
||||
BuildContext context,
|
||||
Map<String, String> suggestion,
|
||||
Client client,
|
||||
) {
|
||||
const size = 30.0;
|
||||
const padding = EdgeInsets.all(4.0);
|
||||
if (suggestion['type'] == 'command') {
|
||||
final command = suggestion['name'];
|
||||
return Container(
|
||||
padding: padding,
|
||||
height: size + padding.bottom + padding.top,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('/' + command,
|
||||
style: const TextStyle(fontFamily: 'monospace')),
|
||||
Text(_commandHint(L10n.of(context), command),
|
||||
style: Theme.of(context).textTheme.caption),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'emote') {
|
||||
final ratio = MediaQuery.of(context).devicePixelRatio;
|
||||
final url = Uri.parse(suggestion['mxc'] ?? '')?.getThumbnail(
|
||||
room.client,
|
||||
width: size * ratio,
|
||||
height: size * ratio,
|
||||
method: ThumbnailMethod.scale,
|
||||
animated: true,
|
||||
);
|
||||
return Container(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CachedNetworkImage(
|
||||
imageUrl: url.toString(),
|
||||
width: size,
|
||||
height: size,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(suggestion['name']),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Opacity(
|
||||
opacity: suggestion['pack_avatar_url'] != null ? 0.8 : 0.5,
|
||||
child: suggestion['pack_avatar_url'] != null
|
||||
? Avatar(
|
||||
Uri.parse(suggestion['pack_avatar_url']),
|
||||
suggestion['pack_display_name'],
|
||||
size: size * 0.9,
|
||||
client: client,
|
||||
)
|
||||
: Text(suggestion['pack_display_name']),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'user' || suggestion['type'] == 'room') {
|
||||
final url = Uri.parse(suggestion['avatar_url'] ?? '');
|
||||
return Container(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Avatar(
|
||||
url,
|
||||
suggestion['displayname'] ?? suggestion['mxid'],
|
||||
size: size,
|
||||
client: client,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(suggestion['displayname'] ?? suggestion['mxid']),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
}
|
||||
|
||||
void insertSuggestion(_, Map<String, String> suggestion) {
|
||||
final replaceText =
|
||||
controller.text.substring(0, controller.selection.baseOffset);
|
||||
var startText = '';
|
||||
final afterText = replaceText == controller.text
|
||||
? ''
|
||||
: controller.text.substring(controller.selection.baseOffset + 1);
|
||||
var insertText = '';
|
||||
if (suggestion['type'] == 'command') {
|
||||
insertText = suggestion['name'] + ' ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'^(\/[\w]*)$'),
|
||||
(Match m) => '/' + insertText,
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'emote') {
|
||||
var isUnique = true;
|
||||
final insertEmote = suggestion['name'];
|
||||
final insertPack = suggestion['pack'];
|
||||
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
|
||||
for (final pack in emotePacks.entries) {
|
||||
if (pack.key == insertPack) {
|
||||
continue;
|
||||
}
|
||||
for (final emote in pack.value.images.entries) {
|
||||
if (emote.key == insertEmote) {
|
||||
isUnique = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isUnique) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
insertText = ':${isUnique ? '' : insertPack + '~'}$insertEmote: ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'(\s|^)(:(?:[-\w]+~)?[-\w]+)$'),
|
||||
(Match m) => '${m[1]}$insertText',
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'user') {
|
||||
insertText = suggestion['mention'] + ' ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'(\s|^)(@[-\w]+)$'),
|
||||
(Match m) => '${m[1]}$insertText',
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'room') {
|
||||
insertText = suggestion['mxid'] + ' ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'(\s|^)(#[-\w]+)$'),
|
||||
(Match m) => '${m[1]}$insertText',
|
||||
);
|
||||
}
|
||||
if (insertText.isNotEmpty && startText.isNotEmpty) {
|
||||
controller.text = startText + afterText;
|
||||
controller.selection = TextSelection(
|
||||
baseOffset: startText.length,
|
||||
extentOffset: startText.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final useShortCuts = (PlatformInfos.isWeb ||
|
||||
PlatformInfos.isDesktop ||
|
||||
AppConfig.sendOnEnter);
|
||||
return Shortcuts(
|
||||
shortcuts: !useShortCuts
|
||||
? {}
|
||||
: {
|
||||
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.enter):
|
||||
NewLineIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): SubmitLineIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: !useShortCuts
|
||||
? {}
|
||||
: {
|
||||
NewLineIntent: CallbackAction(onInvoke: (i) {
|
||||
final val = controller.value;
|
||||
final selection = val.selection.start;
|
||||
final messageWithoutNewLine =
|
||||
controller.text.substring(0, val.selection.start) +
|
||||
'\n' +
|
||||
controller.text.substring(val.selection.end);
|
||||
controller.value = TextEditingValue(
|
||||
text: messageWithoutNewLine,
|
||||
selection: TextSelection.fromPosition(
|
||||
TextPosition(offset: selection + 1),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}),
|
||||
SubmitLineIntent: CallbackAction(onInvoke: (i) {
|
||||
onSubmitted(controller.text);
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: TypeAheadField<Map<String, String>>(
|
||||
direction: AxisDirection.up,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
keepSuggestionsOnSuggestionSelected: true,
|
||||
debounceDuration: const Duration(
|
||||
milliseconds:
|
||||
50), // show suggestions after 50ms idle time (default is 300)
|
||||
textFieldConfiguration: TextFieldConfiguration(
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
autofocus: autofocus,
|
||||
onSubmitted: (text) {
|
||||
// fix for library for now
|
||||
// it sets the types for the callback incorrectly
|
||||
onSubmitted(text);
|
||||
},
|
||||
//focusNode: focusNode,
|
||||
controller: controller,
|
||||
decoration: decoration,
|
||||
focusNode: focusNode,
|
||||
onChanged: (text) {
|
||||
// fix for the library for now
|
||||
// it sets the types for the callback incorrectly
|
||||
onChanged(text);
|
||||
},
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
suggestionsCallback: getSuggestions,
|
||||
itemBuilder: (c, s) =>
|
||||
buildSuggestion(c, s, Matrix.of(context).client),
|
||||
onSuggestionSelected: (Map<String, String> suggestion) =>
|
||||
insertSuggestion(context, suggestion),
|
||||
errorBuilder: (BuildContext context, Object error) => Container(),
|
||||
loadingBuilder: (BuildContext context) =>
|
||||
Container(), // fix loading briefly flickering a dark box
|
||||
noItemsFoundBuilder: (BuildContext context) =>
|
||||
Container(), // fix loading briefly showing no suggestions
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NewLineIntent extends Intent {}
|
||||
|
||||
class SubmitLineIntent extends Intent {}
|
||||
197
lib/pages/chat/recording_dialog.dart
Normal file
197
lib/pages/chat/recording_dialog.dart
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:wakelock/wakelock.dart';
|
||||
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/utils/sentry_controller.dart';
|
||||
|
||||
class RecordingDialog extends StatefulWidget {
|
||||
static const String recordingFileType = 'm4a';
|
||||
const RecordingDialog({
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_RecordingDialogState createState() => _RecordingDialogState();
|
||||
}
|
||||
|
||||
class _RecordingDialogState extends State<RecordingDialog> {
|
||||
Timer _recorderSubscription;
|
||||
Duration _duration = Duration.zero;
|
||||
|
||||
bool error = false;
|
||||
String _recordedPath;
|
||||
final _audioRecorder = Record();
|
||||
Amplitude _amplitude;
|
||||
|
||||
Future<void> startRecording() async {
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
_recordedPath =
|
||||
'${tempDir.path}/recording${DateTime.now().microsecondsSinceEpoch}.${RecordingDialog.recordingFileType}';
|
||||
|
||||
final result = await _audioRecorder.hasPermission();
|
||||
if (result != true) {
|
||||
setState(() => error = true);
|
||||
return;
|
||||
}
|
||||
await Wakelock.enable();
|
||||
await _audioRecorder.start(
|
||||
path: _recordedPath, encoder: AudioEncoder.AAC);
|
||||
setState(() => _duration = Duration.zero);
|
||||
_recorderSubscription?.cancel();
|
||||
_recorderSubscription =
|
||||
Timer.periodic(const Duration(milliseconds: 100), (_) async {
|
||||
_amplitude = await _audioRecorder.getAmplitude();
|
||||
setState(() {
|
||||
_duration += const Duration(milliseconds: 100);
|
||||
});
|
||||
});
|
||||
} catch (e, s) {
|
||||
SentryController.captureException(e, s);
|
||||
setState(() => error = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
startRecording();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Wakelock.disable();
|
||||
_recorderSubscription?.cancel();
|
||||
_audioRecorder.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _stopAndSend() async {
|
||||
_recorderSubscription?.cancel();
|
||||
await _audioRecorder.stop();
|
||||
Navigator.of(context, rootNavigator: false)
|
||||
.pop<RecordingResult>(RecordingResult(
|
||||
path: _recordedPath,
|
||||
duration: _duration.inMilliseconds,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const maxDecibalWidth = 64.0;
|
||||
final decibalWidth =
|
||||
((_amplitude == null || _amplitude.current == double.negativeInfinity
|
||||
? 0
|
||||
: 1 / _amplitude.current / _amplitude.max)
|
||||
.abs() +
|
||||
2) *
|
||||
(maxDecibalWidth / 4).toDouble();
|
||||
final time =
|
||||
'${_duration.inMinutes.toString().padLeft(2, '0')}:${(_duration.inSeconds % 60).toString().padLeft(2, '0')}';
|
||||
final content = error
|
||||
? Text(L10n.of(context).oopsSomethingWentWrong)
|
||||
: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: maxDecibalWidth,
|
||||
height: maxDecibalWidth,
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
width: decibalWidth,
|
||||
height: decibalWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(decibalWidth),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${L10n.of(context).recording}: $time',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (PlatformInfos.isCupertinoStyle) {
|
||||
return CupertinoAlertDialog(
|
||||
content: content,
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
|
||||
child: Text(
|
||||
L10n.of(context).cancel.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).textTheme.bodyText2.color.withAlpha(150),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (error != true)
|
||||
CupertinoDialogAction(
|
||||
onPressed: _stopAndSend,
|
||||
child: Text(L10n.of(context).send.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return AlertDialog(
|
||||
content: content,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
|
||||
child: Text(
|
||||
L10n.of(context).cancel.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.bodyText2.color.withAlpha(150),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (error != true)
|
||||
TextButton(
|
||||
onPressed: _stopAndSend,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(L10n.of(context).send.toUpperCase()),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.send_outlined, size: 15),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingResult {
|
||||
final String path;
|
||||
final int duration;
|
||||
|
||||
const RecordingResult({
|
||||
@required this.path,
|
||||
@required this.duration,
|
||||
});
|
||||
|
||||
factory RecordingResult.fromJson(Map<String, dynamic> json) =>
|
||||
RecordingResult(
|
||||
path: json['path'],
|
||||
duration: json['duration'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'duration': duration,
|
||||
};
|
||||
}
|
||||
135
lib/pages/chat/sticker_picker_dialog.dart
Normal file
135
lib/pages/chat/sticker_picker_dialog.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../widgets/avatar.dart';
|
||||
import '../../widgets/default_app_bar_search_field.dart';
|
||||
import 'events/image_bubble.dart';
|
||||
|
||||
class StickerPickerDialog extends StatefulWidget {
|
||||
final Room room;
|
||||
|
||||
const StickerPickerDialog({this.room, Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
StickerPickerDialogState createState() => StickerPickerDialogState();
|
||||
}
|
||||
|
||||
class StickerPickerDialogState extends State<StickerPickerDialog> {
|
||||
String searchFilter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stickerPacks = widget.room.getImagePacks(ImagePackUsage.sticker);
|
||||
final packSlugs = stickerPacks.keys.toList();
|
||||
|
||||
// ignore: prefer_function_declarations_over_variables
|
||||
final _packBuilder = (BuildContext context, int packIndex) {
|
||||
final pack = stickerPacks[packSlugs[packIndex]];
|
||||
final filteredImagePackImageEntried = pack.images.entries.toList();
|
||||
if (searchFilter?.isNotEmpty ?? false) {
|
||||
filteredImagePackImageEntried.removeWhere((e) =>
|
||||
!(e.key.toLowerCase().contains(searchFilter.toLowerCase()) ||
|
||||
(e.value.body
|
||||
?.toLowerCase()
|
||||
?.contains(searchFilter.toLowerCase()) ??
|
||||
false)));
|
||||
}
|
||||
final imageKeys =
|
||||
filteredImagePackImageEntried.map((e) => e.key).toList();
|
||||
if (imageKeys.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
final packName = pack.pack.displayName ?? packSlugs[packIndex];
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
if (packIndex != 0) const SizedBox(height: 20),
|
||||
if (packName != 'user')
|
||||
ListTile(
|
||||
leading: Avatar(
|
||||
pack.pack.avatarUrl,
|
||||
packName,
|
||||
client: widget.room.client,
|
||||
),
|
||||
title: Text(packName),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
GridView.builder(
|
||||
itemCount: imageKeys.length,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 100),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (BuildContext context, int imageIndex) {
|
||||
final image = pack.images[imageKeys[imageIndex]];
|
||||
final fakeEvent = Event.fromJson(<String, dynamic>{
|
||||
'type': EventTypes.Sticker,
|
||||
'content': <String, dynamic>{
|
||||
'url': image.url.toString(),
|
||||
'info': image.info,
|
||||
},
|
||||
'event_id': 'fake_event',
|
||||
}, widget.room);
|
||||
return InkWell(
|
||||
key: ValueKey(image.url.toString()),
|
||||
onTap: () {
|
||||
// copy the image
|
||||
final imageCopy =
|
||||
ImagePackImageContent.fromJson(image.toJson().copy());
|
||||
// set the body, if it doesn't exist, to the key
|
||||
imageCopy.body ??= imageKeys[imageIndex];
|
||||
Navigator.of(context, rootNavigator: false)
|
||||
.pop<ImagePackImageContent>(imageCopy);
|
||||
},
|
||||
child: AbsorbPointer(
|
||||
absorbing: true,
|
||||
child: ImageBubble(
|
||||
fakeEvent,
|
||||
tapToView: false,
|
||||
fit: BoxFit.contain,
|
||||
width: 100,
|
||||
height: 100,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
body: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: Navigator.of(context, rootNavigator: false).pop,
|
||||
),
|
||||
title: DefaultAppBarSearchField(
|
||||
autofocus: false,
|
||||
hintText: L10n.of(context).search,
|
||||
suffix: const Icon(Icons.search_outlined),
|
||||
onChanged: (s) => setState(() => searchFilter = s),
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
_packBuilder,
|
||||
childCount: packSlugs.length,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue