- Replies: Reply on a message's long press puts it above the composer; sent replies quote the message they answer (sender and first line), with the reply fallback left out of the body. - Reactions: six quick ones and any other on long press, chips under the message with counts, the user's own highlighted; tapping a chip adds or takes back a reaction, a long press lists who reacted. - Threads: Reply in thread opens a thread view (the root, then its replies, older ones fetched from /relations, new ones live from the room timeline) with a composer that sends into the thread. Thread replies are hidden from the main timeline; the root shows "N replies". Room info lists the room's threads (/threads). The composer moved out of the room screen into widgets/composer.dart so the room and thread views share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
784 lines
24 KiB
Dart
784 lines
24 KiB
Dart
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../matrix/client.dart';
|
|
import '../matrix/media.dart';
|
|
import '../theme.dart';
|
|
import '../util/format.dart';
|
|
import 'avatar.dart';
|
|
import 'message_extras.dart';
|
|
|
|
const _i18n = MatrixDefaultLocalizations();
|
|
|
|
/// A one-line room event in the middle of the timeline: "Alice joined".
|
|
class NoticeRow extends StatelessWidget {
|
|
const NoticeRow({super.key, required this.event});
|
|
final Event event;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 6),
|
|
child: Text(
|
|
event.calcLocalizedBodyFallback(_i18n),
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelMedium?.copyWith(color: c.inkMuted),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A message: a bubble on the right for the user's own, on the left with
|
|
/// the sender's avatar and name for everyone else's.
|
|
class MessageRow extends StatelessWidget {
|
|
const MessageRow({
|
|
super.key,
|
|
required this.event,
|
|
required this.timeline,
|
|
required this.firstInGroup,
|
|
required this.lastInGroup,
|
|
required this.readByOthers,
|
|
this.onReply,
|
|
this.onOpenThread,
|
|
});
|
|
|
|
final Event event;
|
|
final Timeline timeline;
|
|
final bool firstInGroup;
|
|
final bool lastInGroup;
|
|
final bool readByOthers;
|
|
|
|
/// Starts a reply to this message in the composer.
|
|
final ValueChanged<Event>? onReply;
|
|
|
|
/// Opens the thread this message starts (or would start). Null inside a
|
|
/// thread, where there is no thread to open.
|
|
final ValueChanged<Event>? onOpenThread;
|
|
|
|
bool get _mine => event.senderId == event.room.client.userID;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final text = Theme.of(context).textTheme;
|
|
final display = event.getDisplayEvent(timeline);
|
|
final sender = event.senderFromMemoryOrFallback;
|
|
final showSender = !_mine && firstInGroup && !event.room.isDirectChat;
|
|
final bubbleColour = _mine ? c.tealSoft : c.bubbleOther;
|
|
final isMedia =
|
|
!display.redacted &&
|
|
(display.messageType == MessageTypes.Image ||
|
|
display.messageType == MessageTypes.Sticker);
|
|
|
|
final radius = Radius.circular(18);
|
|
final tight = Radius.circular(6);
|
|
final shape = BorderRadius.only(
|
|
topLeft: !_mine && !firstInGroup ? tight : radius,
|
|
bottomLeft: !_mine && !lastInGroup ? tight : radius,
|
|
topRight: _mine && !firstInGroup ? tight : radius,
|
|
bottomRight: _mine && !lastInGroup ? tight : radius,
|
|
);
|
|
|
|
final replyTo = event.inReplyToEventId(includingFallback: false);
|
|
// A thread reply's in-reply-to is the thread's fallback: not shown.
|
|
final isThreadFallback =
|
|
event.relationshipType == RelationshipTypes.thread &&
|
|
event.content.tryGetMap<String, Object?>(
|
|
'm.relates_to',
|
|
)?['is_falling_back'] ==
|
|
true;
|
|
final content = _MessageBody(event: display, original: event, mine: _mine);
|
|
final body = replyTo == null || display.redacted || isThreadFallback
|
|
? content
|
|
: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: isMedia
|
|
? const EdgeInsets.all(6)
|
|
: const EdgeInsets.only(bottom: 6),
|
|
child: ReplyPreview(event: event, timeline: timeline),
|
|
),
|
|
content,
|
|
],
|
|
);
|
|
final bubble = GestureDetector(
|
|
onLongPress: () => _showActions(context, display),
|
|
child: isMedia
|
|
? ClipRRect(borderRadius: shape, child: body)
|
|
: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
|
decoration: BoxDecoration(
|
|
color: bubbleColour,
|
|
borderRadius: shape,
|
|
),
|
|
child: body,
|
|
),
|
|
);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
_mine ? 56 : 12,
|
|
firstInGroup ? 8 : 2,
|
|
_mine ? 12 : 56,
|
|
lastInGroup ? 4 : 0,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: _mine
|
|
? MainAxisAlignment.end
|
|
: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
if (!_mine) ...[
|
|
SizedBox(
|
|
width: 32,
|
|
child: lastInGroup
|
|
? Avatar(
|
|
name: sender.calcDisplayname(),
|
|
seed: sender.id,
|
|
mxc: sender.avatarUrl,
|
|
size: 32,
|
|
)
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
Flexible(
|
|
child: Column(
|
|
crossAxisAlignment: _mine
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
if (showSender)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4, bottom: 2),
|
|
child: Text(
|
|
sender.calcDisplayname(),
|
|
style: text.labelMedium?.copyWith(
|
|
color: c.tealText,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
bubble,
|
|
Reactions(event: event, timeline: timeline),
|
|
if (onOpenThread != null)
|
|
ThreadSummary(
|
|
event: event,
|
|
timeline: timeline,
|
|
onTap: () => onOpenThread!(event),
|
|
),
|
|
if (lastInGroup || event.status.isError)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 3, left: 4, right: 4),
|
|
child: _Status(
|
|
event: event,
|
|
edited:
|
|
display != event ||
|
|
event.hasAggregatedEvents(
|
|
timeline,
|
|
RelationshipTypes.edit,
|
|
),
|
|
mine: _mine,
|
|
read: readByOthers,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showActions(BuildContext context, Event display) async {
|
|
final hasText =
|
|
!display.redacted &&
|
|
display.type == EventTypes.Message &&
|
|
!display.hasAttachment;
|
|
final canSave = !display.redacted && display.hasAttachment;
|
|
final canDelete =
|
|
!event.redacted &&
|
|
event.status.isSent &&
|
|
event.canRedact &&
|
|
event.room.canRedact;
|
|
final failed = event.status.isError;
|
|
final canRespond =
|
|
event.status.isSent &&
|
|
!event.redacted &&
|
|
event.room.canSendDefaultMessages;
|
|
|
|
final action = await showModalBottomSheet<String>(
|
|
context: context,
|
|
builder: (sheet) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (canRespond && event.room.canSendEvent(EventTypes.Reaction))
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
for (final emoji in quickReactions)
|
|
ReactionButton(
|
|
emoji: emoji,
|
|
selected: myReaction(event, timeline, emoji) != null,
|
|
onTap: () => Navigator.pop(sheet, 'react:$emoji'),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Other reaction',
|
|
icon: const Icon(Icons.add_reaction_outlined),
|
|
onPressed: () => Navigator.pop(sheet, 'react-other'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (canRespond && onReply != null)
|
|
ListTile(
|
|
leading: const Icon(Icons.reply),
|
|
title: const Text('Reply'),
|
|
onTap: () => Navigator.pop(sheet, 'reply'),
|
|
),
|
|
if (canRespond &&
|
|
onOpenThread != null &&
|
|
event.relationshipType != RelationshipTypes.thread)
|
|
ListTile(
|
|
leading: const Icon(Icons.forum_outlined),
|
|
title: const Text('Reply in thread'),
|
|
onTap: () => Navigator.pop(sheet, 'thread'),
|
|
),
|
|
if (failed) ...[
|
|
ListTile(
|
|
leading: const Icon(Icons.refresh),
|
|
title: const Text('Retry'),
|
|
onTap: () => Navigator.pop(sheet, 'retry'),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.delete_outline),
|
|
title: const Text('Delete'),
|
|
onTap: () => Navigator.pop(sheet, 'cancel'),
|
|
),
|
|
],
|
|
if (hasText)
|
|
ListTile(
|
|
leading: const Icon(Icons.copy),
|
|
title: const Text('Copy text'),
|
|
onTap: () => Navigator.pop(sheet, 'copy'),
|
|
),
|
|
if (canSave)
|
|
ListTile(
|
|
leading: const Icon(Icons.download_outlined),
|
|
title: const Text('Save'),
|
|
onTap: () => Navigator.pop(sheet, 'save'),
|
|
),
|
|
if (canDelete)
|
|
ListTile(
|
|
leading: Icon(Icons.delete_outline, color: context.warp.danger),
|
|
title: Text(
|
|
'Delete',
|
|
style: TextStyle(color: context.warp.danger),
|
|
),
|
|
onTap: () => Navigator.pop(sheet, 'redact'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (!context.mounted) return;
|
|
try {
|
|
if (action != null && action.startsWith('react:')) {
|
|
await toggleReaction(event, timeline, action.substring(6));
|
|
return;
|
|
}
|
|
switch (action) {
|
|
case 'react-other':
|
|
final emoji = await askReaction(context);
|
|
if (emoji != null) await toggleReaction(event, timeline, emoji);
|
|
case 'reply':
|
|
onReply?.call(event);
|
|
case 'thread':
|
|
onOpenThread?.call(event);
|
|
case 'retry':
|
|
await event.sendAgain();
|
|
case 'cancel':
|
|
await event.cancelSend();
|
|
case 'copy':
|
|
await Clipboard.setData(
|
|
ClipboardData(
|
|
text: display.calcLocalizedBodyFallback(
|
|
_i18n,
|
|
hideReply: true,
|
|
plaintextBody: false,
|
|
),
|
|
),
|
|
);
|
|
case 'save':
|
|
await saveAttachment(context, display);
|
|
case 'redact':
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: const Text('Delete message?'),
|
|
content: const Text(
|
|
'It is removed for everyone in the room. This can\'t be '
|
|
'undone.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: context.warp.danger,
|
|
),
|
|
onPressed: () => Navigator.pop(dialog, true),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok == true) await event.redactEvent();
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(describeError(e))));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Under a group: the time, "edited", and for the user's own messages
|
|
/// whether it is sending, sent, read, or failed.
|
|
class _Status extends StatelessWidget {
|
|
const _Status({
|
|
required this.event,
|
|
required this.edited,
|
|
required this.mine,
|
|
required this.read,
|
|
});
|
|
|
|
final Event event;
|
|
final bool edited;
|
|
final bool mine;
|
|
final bool read;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final style = Theme.of(
|
|
context,
|
|
).textTheme.labelSmall?.copyWith(color: c.inkMuted);
|
|
final status = event.status;
|
|
final parts = <InlineSpan>[
|
|
TextSpan(text: formatTime(event.originServerTs)),
|
|
if (edited) const TextSpan(text: ' · edited'),
|
|
];
|
|
if (mine) {
|
|
if (status.isError) {
|
|
parts.add(
|
|
TextSpan(
|
|
text: ' · Not sent. Hold for Retry',
|
|
style: style?.copyWith(color: c.danger),
|
|
),
|
|
);
|
|
} else if (status.isSending) {
|
|
parts.add(const TextSpan(text: ' · Sending'));
|
|
} else if (read) {
|
|
parts.add(
|
|
TextSpan(
|
|
text: ' · Read',
|
|
style: style?.copyWith(color: c.tealText),
|
|
),
|
|
);
|
|
} else {
|
|
parts.add(const TextSpan(text: ' · Sent'));
|
|
}
|
|
}
|
|
return Text.rich(TextSpan(children: parts), style: style);
|
|
}
|
|
}
|
|
|
|
class _MessageBody extends StatelessWidget {
|
|
const _MessageBody({
|
|
required this.event,
|
|
required this.original,
|
|
required this.mine,
|
|
});
|
|
|
|
/// The event as shown: the latest edit of [original].
|
|
final Event event;
|
|
final Event original;
|
|
final bool mine;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final text = Theme.of(context).textTheme;
|
|
final muted = text.bodyLarge?.copyWith(
|
|
color: c.inkMuted,
|
|
fontStyle: FontStyle.italic,
|
|
);
|
|
|
|
if (event.redacted) {
|
|
return Text('Message deleted', style: muted);
|
|
}
|
|
if (event.type == EventTypes.Encrypted) {
|
|
return _UndecryptableBody(event: event);
|
|
}
|
|
|
|
switch (event.messageType) {
|
|
case MessageTypes.Image:
|
|
case MessageTypes.Sticker:
|
|
return _ImageBody(event: event);
|
|
case MessageTypes.File:
|
|
case MessageTypes.Video:
|
|
case MessageTypes.Audio:
|
|
return _FileBody(event: event);
|
|
case MessageTypes.BadEncrypted:
|
|
return _UndecryptableBody(event: event);
|
|
}
|
|
|
|
final body = event.calcLocalizedBodyFallback(
|
|
_i18n,
|
|
hideReply: true,
|
|
plaintextBody: false,
|
|
);
|
|
final style = event.messageType == MessageTypes.Emote
|
|
? text.bodyLarge?.copyWith(fontStyle: FontStyle.italic)
|
|
: event.messageType == MessageTypes.Notice
|
|
? text.bodyLarge?.copyWith(color: c.inkMuted)
|
|
: text.bodyLarge;
|
|
final emote = event.messageType == MessageTypes.Emote
|
|
? '* ${event.senderFromMemoryOrFallback.calcDisplayname()} '
|
|
: '';
|
|
return LinkifiedText('$emote$body', style: style);
|
|
}
|
|
}
|
|
|
|
/// A message this session has no key for: why, in words, rather than just
|
|
/// "encrypted".
|
|
class _UndecryptableBody extends StatelessWidget {
|
|
const _UndecryptableBody({required this.event});
|
|
final Event event;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final client = event.room.client;
|
|
final String reason;
|
|
if (!client.encryptionEnabled) {
|
|
reason = "Encrypted. This app can't decrypt messages.";
|
|
} else if (client.isUnknownSession) {
|
|
reason =
|
|
"Can't decrypt this yet. Verify this session in Settings to read "
|
|
'older messages.';
|
|
} else if (event.content['can_request_session'] == true) {
|
|
reason =
|
|
'Waiting for the key to this message. It may have been sent '
|
|
'before this session signed in.';
|
|
} else {
|
|
reason = "Couldn't decrypt this message.";
|
|
}
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.lock_clock_outlined, size: 16, color: c.inkMuted),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
reason,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: c.inkMuted,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImageBody extends StatefulWidget {
|
|
const _ImageBody({required this.event});
|
|
final Event event;
|
|
|
|
@override
|
|
State<_ImageBody> createState() => _ImageBodyState();
|
|
}
|
|
|
|
class _ImageBodyState extends State<_ImageBody> {
|
|
late Future<Uint8List?> _bytes = Media.attachment(
|
|
widget.event,
|
|
thumbnail: true,
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final info = widget.event.infoMap;
|
|
final w = (info['w'] as num?)?.toDouble();
|
|
final h = (info['h'] as num?)?.toDouble();
|
|
final maxWidth = MediaQuery.sizeOf(context).width * 0.66;
|
|
var width = maxWidth.clamp(120.0, 320.0);
|
|
var height = width * 0.75;
|
|
if (w != null && h != null && w > 0 && h > 0) {
|
|
height = (width * h / w).clamp(80.0, 360.0);
|
|
if (h > w) width = (height * w / h).clamp(80.0, width);
|
|
}
|
|
|
|
return FutureBuilder<Uint8List?>(
|
|
future: _bytes,
|
|
builder: (context, snapshot) {
|
|
final data = snapshot.data;
|
|
final placeholder = Container(
|
|
width: width,
|
|
height: height,
|
|
color: c.bubbleOther,
|
|
alignment: Alignment.center,
|
|
child: snapshot.connectionState == ConnectionState.done
|
|
? IconButton(
|
|
tooltip: 'Retry',
|
|
icon: Icon(Icons.broken_image_outlined, color: c.inkMuted),
|
|
onPressed: () => setState(() {
|
|
Media.evict(widget.event);
|
|
_bytes = Media.attachment(widget.event, thumbnail: true);
|
|
}),
|
|
)
|
|
: const SizedBox.square(
|
|
dimension: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
);
|
|
if (data == null) return placeholder;
|
|
return GestureDetector(
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (_) => ImageViewer(event: widget.event)),
|
|
),
|
|
child: Image.memory(
|
|
data,
|
|
width: width,
|
|
height: height,
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => placeholder,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FileBody extends StatefulWidget {
|
|
const _FileBody({required this.event});
|
|
final Event event;
|
|
|
|
@override
|
|
State<_FileBody> createState() => _FileBodyState();
|
|
}
|
|
|
|
class _FileBodyState extends State<_FileBody> {
|
|
bool _saving = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final text = Theme.of(context).textTheme;
|
|
final event = widget.event;
|
|
final size = event.infoMap['size'];
|
|
final icon = switch (event.messageType) {
|
|
MessageTypes.Video => Icons.movie_outlined,
|
|
MessageTypes.Audio => Icons.audiotrack_outlined,
|
|
_ => Icons.insert_drive_file_outlined,
|
|
};
|
|
return InkWell(
|
|
onTap: _saving
|
|
? null
|
|
: () async {
|
|
setState(() => _saving = true);
|
|
try {
|
|
await saveAttachment(context, event);
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
},
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: c.surface,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: _saving
|
|
? const Padding(
|
|
padding: EdgeInsets.all(10),
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(icon, color: c.tealText),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Flexible(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
event.body,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: text.titleSmall,
|
|
),
|
|
Text(
|
|
[
|
|
if (size is int) formatBytes(size),
|
|
'Tap to save',
|
|
].join(' · '),
|
|
style: text.labelSmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Downloads (and decrypts) an attachment and asks where to save it.
|
|
Future<void> saveAttachment(BuildContext context, Event event) async {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
try {
|
|
final file = await event.downloadAndDecryptAttachment();
|
|
final saved = await FilePicker.saveFile(
|
|
fileName: file.name,
|
|
bytes: file.bytes,
|
|
);
|
|
if (saved != null) {
|
|
messenger.showSnackBar(SnackBar(content: Text('Saved ${file.name}')));
|
|
}
|
|
} catch (e) {
|
|
messenger.showSnackBar(SnackBar(content: Text(describeError(e))));
|
|
}
|
|
}
|
|
|
|
/// An image full screen, pinch to zoom.
|
|
class ImageViewer extends StatelessWidget {
|
|
const ImageViewer({super.key, required this.event});
|
|
final Event event;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: Colors.white,
|
|
shape: const Border(),
|
|
title: Text(
|
|
event.body,
|
|
style: const TextStyle(color: Colors.white, fontSize: 16),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Save',
|
|
icon: const Icon(Icons.download_outlined),
|
|
onPressed: () => saveAttachment(context, event),
|
|
),
|
|
],
|
|
),
|
|
body: FutureBuilder<Uint8List?>(
|
|
future: Media.attachment(event),
|
|
builder: (context, snapshot) {
|
|
final data = snapshot.data;
|
|
if (data == null) {
|
|
return Center(
|
|
child: snapshot.connectionState == ConnectionState.done
|
|
? const Text(
|
|
"Couldn't load the image.",
|
|
style: TextStyle(color: Colors.white),
|
|
)
|
|
: const CircularProgressIndicator(),
|
|
);
|
|
}
|
|
return InteractiveViewer(
|
|
maxScale: 6,
|
|
child: Center(child: Image.memory(data)),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Text with web links that open in the browser.
|
|
class LinkifiedText extends StatefulWidget {
|
|
const LinkifiedText(this.text, {super.key, this.style});
|
|
final String text;
|
|
final TextStyle? style;
|
|
|
|
static final _url = RegExp(
|
|
r'''\bhttps?://[^\s<>"']+[^\s<>"'.,;:!?)\]]''',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
@override
|
|
State<LinkifiedText> createState() => _LinkifiedTextState();
|
|
}
|
|
|
|
class _LinkifiedTextState extends State<LinkifiedText> {
|
|
final _recognizers = <TapGestureRecognizer>[];
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final r in _recognizers) {
|
|
r.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
for (final r in _recognizers) {
|
|
r.dispose();
|
|
}
|
|
_recognizers.clear();
|
|
final text = widget.text;
|
|
final matches = LinkifiedText._url.allMatches(text).toList();
|
|
if (matches.isEmpty) return Text(text, style: widget.style);
|
|
|
|
final link = widget.style?.copyWith(
|
|
color: context.warp.tealText,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: context.warp.tealText,
|
|
);
|
|
final spans = <InlineSpan>[];
|
|
var at = 0;
|
|
for (final m in matches) {
|
|
if (m.start > at) spans.add(TextSpan(text: text.substring(at, m.start)));
|
|
final url = m.group(0)!;
|
|
final recognizer = TapGestureRecognizer()
|
|
..onTap = () =>
|
|
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
|
_recognizers.add(recognizer);
|
|
spans.add(TextSpan(text: url, style: link, recognizer: recognizer));
|
|
at = m.end;
|
|
}
|
|
if (at < text.length) spans.add(TextSpan(text: text.substring(at)));
|
|
return Text.rich(TextSpan(children: spans), style: widget.style);
|
|
}
|
|
}
|