refactor: Pages folder structure

This commit is contained in:
Krille Fear 2021-11-09 21:32:16 +01:00
commit 1abb7310f3
88 changed files with 188 additions and 250 deletions

View 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,
),
),
],
),
);
}
}

View 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;
},
);
}
}

View 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});
}

View 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,
),
),
],
),
],
),
),
),
);
}
}

View 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,
),
],
);
}
}

View 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
}
}

View 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,
),
),
],
);
}
}

View 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,
);
}
}

View 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,
],
),
),
],
);
}
}

View 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,
),
),
],
),
),
),
),
);
}
}

View 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,
);
}
}

View 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)))
],
),
),
),
);
}
}