refactor: Pages folder structure
This commit is contained in:
parent
5fe495db94
commit
1abb7310f3
88 changed files with 188 additions and 250 deletions
|
|
@ -1,115 +0,0 @@
|
|||
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 '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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
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 '../../config/app_config.dart';
|
||||
import '../../config/setting_keys.dart';
|
||||
import '../../pages/image_viewer.dart';
|
||||
import '../../utils/matrix_sdk_extensions.dart/matrix_locals.dart';
|
||||
import '../../utils/url_launcher.dart';
|
||||
import '../matrix.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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
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.dart';
|
||||
import '../../utils/matrix_sdk_extensions.dart/event_extension.dart';
|
||||
import '../matrix.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});
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
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/event_content/message_content.dart';
|
||||
import 'package:fluffychat/widgets/event_content/reply_content.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../avatar.dart';
|
||||
import '../matrix.dart';
|
||||
import 'message_reactions.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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
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_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/event_content/audio_player.dart';
|
||||
import 'package:fluffychat/widgets/event_content/image_bubble.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../pages/video_viewer.dart';
|
||||
import '../../utils/platform_infos.dart';
|
||||
import '../../utils/url_launcher.dart';
|
||||
import '../matrix.dart';
|
||||
import 'html_message.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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
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 '../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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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)))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,445 +0,0 @@
|
|||
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 'avatar.dart';
|
||||
import '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 {}
|
||||
44
lib/widgets/layouts/empty_page.dart
Normal file
44
lib/widgets/layouts/empty_page.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmptyPage extends StatelessWidget {
|
||||
final bool loading;
|
||||
static const double _width = 200;
|
||||
const EmptyPage({this.loading = false, Key key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _width = min(MediaQuery.of(context).size.width, EmptyPage._width);
|
||||
return Scaffold(
|
||||
// Add invisible appbar to make status bar on Android tablets bright.
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: 'info-logo',
|
||||
child: Image.asset(
|
||||
'assets/info-logo.png',
|
||||
width: _width,
|
||||
height: _width,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (loading)
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: _width,
|
||||
child: const LinearProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:matrix/matrix.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/pages/views/empty_page_view.dart';
|
||||
import 'package:fluffychat/widgets/layouts/empty_page.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class LoadingView extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -1,325 +0,0 @@
|
|||
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:pedantic/pedantic.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.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/utils/room_status_extension.dart';
|
||||
import '../../pages/send_file_dialog.dart';
|
||||
import '../../utils/date_time_extension.dart';
|
||||
import '../avatar.dart';
|
||||
import '../matrix.dart';
|
||||
|
||||
enum ArchivedRoomAction { delete, rejoin }
|
||||
|
||||
class ChatListItem extends StatelessWidget {
|
||||
final Room room;
|
||||
final bool activeChat;
|
||||
final bool selected;
|
||||
final Function onForget;
|
||||
final Function onTap;
|
||||
final Function onLongPress;
|
||||
|
||||
const ChatListItem(
|
||||
this.room, {
|
||||
this.activeChat = false,
|
||||
this.selected = false,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onForget,
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
dynamic clickAction(BuildContext context) async {
|
||||
if (onTap != null) return onTap();
|
||||
if (!activeChat) {
|
||||
if (room.membership == Membership.invite &&
|
||||
(await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final joinedFuture = room.client.onSync.stream
|
||||
.where((u) =>
|
||||
u.rooms?.join?.containsKey(room.id) ?? false)
|
||||
.first;
|
||||
await room.join();
|
||||
await joinedFuture;
|
||||
}))
|
||||
.error !=
|
||||
null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.membership == Membership.ban) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context).youHaveBeenBannedFromThisChat),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.membership == Membership.leave) {
|
||||
final action = await showModalActionSheet<ArchivedRoomAction>(
|
||||
context: context,
|
||||
title: L10n.of(context).archivedRoom,
|
||||
message: L10n.of(context).thisRoomHasBeenArchived,
|
||||
actions: [
|
||||
SheetAction(
|
||||
label: L10n.of(context).rejoin,
|
||||
key: ArchivedRoomAction.rejoin,
|
||||
),
|
||||
SheetAction(
|
||||
label: L10n.of(context).delete,
|
||||
key: ArchivedRoomAction.delete,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (action != null) {
|
||||
switch (action) {
|
||||
case ArchivedRoomAction.delete:
|
||||
await archiveAction(context);
|
||||
break;
|
||||
case ArchivedRoomAction.rejoin:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.join(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (room.membership == Membership.join) {
|
||||
if (Matrix.of(context).shareContent != null) {
|
||||
if (Matrix.of(context).shareContent['msgtype'] ==
|
||||
'chat.fluffy.shared_file') {
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => SendFileDialog(
|
||||
file: Matrix.of(context).shareContent['file'],
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
unawaited(room.sendEvent(Matrix.of(context).shareContent));
|
||||
}
|
||||
Matrix.of(context).shareContent = null;
|
||||
}
|
||||
VRouter.of(context).toSegments(['rooms', room.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> archiveAction(BuildContext context) async {
|
||||
{
|
||||
if ([Membership.leave, Membership.ban].contains(room.membership)) {
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.forget(),
|
||||
);
|
||||
if (success.error == null) {
|
||||
if (onForget != null) onForget();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
final confirmed = await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context).areYouSure,
|
||||
okLabel: L10n.of(context).yes,
|
||||
cancelLabel: L10n.of(context).no,
|
||||
);
|
||||
if (confirmed == OkCancelResult.cancel) return;
|
||||
await showFutureLoadingDialog(
|
||||
context: context, future: () => room.leave());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMuted = room.pushRuleState != PushRuleState.notify;
|
||||
final typingText = room.getLocalizedTypingText(context);
|
||||
final ownMessage =
|
||||
room.lastEvent?.senderId == Matrix.of(context).client.userID;
|
||||
final unread = room.isUnread || room.membership == Membership.invite;
|
||||
final unreadBubbleSize = unread
|
||||
? room.notificationCount > 0
|
||||
? 20.0
|
||||
: 14.0
|
||||
: 0.0;
|
||||
return ListTile(
|
||||
selected: selected || activeChat,
|
||||
selectedTileColor: selected
|
||||
? Theme.of(context).primaryColor.withAlpha(100)
|
||||
: Theme.of(context).secondaryHeaderColor,
|
||||
onLongPress: onLongPress,
|
||||
leading: selected
|
||||
? SizedBox(
|
||||
width: Avatar.defaultSize,
|
||||
height: Avatar.defaultSize,
|
||||
child: Material(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(Avatar.defaultSize),
|
||||
child: const Icon(Icons.check, color: Colors.white),
|
||||
),
|
||||
)
|
||||
: Avatar(room.avatar, room.displayname, onTap: onLongPress),
|
||||
title: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context))),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
fontWeight: unread ? FontWeight.bold : null,
|
||||
color: unread
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Theme.of(context).textTheme.bodyText1.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isMuted)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4.0),
|
||||
child: Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
if (room.isFavourite)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: room.notificationCount > 0 ? 4.0 : 0.0),
|
||||
child: Icon(
|
||||
Icons.push_pin_outlined,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: Text(
|
||||
room.timeCreated.localizedTimeShort(context),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: unread
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Theme.of(context).textTheme.bodyText2.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
if (typingText.isEmpty && ownMessage) ...{
|
||||
Icon(
|
||||
room.lastEvent.statusIcon,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
},
|
||||
AnimatedContainer(
|
||||
width: typingText.isEmpty ? 0 : 18,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.bounceInOut,
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(
|
||||
Icons.edit_outlined,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
if (typingText.isEmpty &&
|
||||
!ownMessage &&
|
||||
!room.isDirectChat &&
|
||||
room.lastEvent != null &&
|
||||
room.lastEvent.type == EventTypes.Message &&
|
||||
{MessageTypes.Text, MessageTypes.Notice}
|
||||
.contains(room.lastEvent.messageType))
|
||||
Text(
|
||||
'${room.lastEvent.sender.calcDisplayname()}: ',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.bodyText1.color,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: typingText.isNotEmpty
|
||||
? Text(
|
||||
typingText,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
softWrap: false,
|
||||
)
|
||||
: Text(
|
||||
room.membership == Membership.invite
|
||||
? L10n.of(context).youAreInvitedToThisChat
|
||||
: room.lastEvent?.getLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
hideReply: true,
|
||||
hideEdit: true,
|
||||
plaintextBody: true,
|
||||
),
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: unread
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Theme.of(context).textTheme.bodyText2.color,
|
||||
decoration: room.lastEvent?.redacted == true
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.bounceInOut,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
height: unreadBubbleSize,
|
||||
width: room.notificationCount == 0 && !unread
|
||||
? 0
|
||||
: (unreadBubbleSize - 9) *
|
||||
room.notificationCount.toString().length +
|
||||
9,
|
||||
decoration: BoxDecoration(
|
||||
color: room.highlightCount > 0
|
||||
? Colors.red
|
||||
: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
child: Center(
|
||||
child: room.notificationCount > 0
|
||||
? Text(
|
||||
room.notificationCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => clickAction(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../pages/user_bottom_sheet.dart';
|
||||
import '../avatar.dart';
|
||||
|
||||
class ParticipantListItem extends StatelessWidget {
|
||||
final User user;
|
||||
|
||||
const ParticipantListItem(this.user, {Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final membershipBatch = <Membership, String>{
|
||||
Membership.join: '',
|
||||
Membership.ban: L10n.of(context).banned,
|
||||
Membership.invite: L10n.of(context).invited,
|
||||
Membership.leave: L10n.of(context).leftTheChat,
|
||||
};
|
||||
final permissionBatch = user.powerLevel == 100
|
||||
? L10n.of(context).admin
|
||||
: user.powerLevel >= 50
|
||||
? L10n.of(context).moderator
|
||||
: '';
|
||||
|
||||
return ListTile(
|
||||
onTap: () => showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (c) => UserBottomSheet(
|
||||
user: user,
|
||||
outerContext: context,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: <Widget>[
|
||||
Text(user.calcDisplayname()),
|
||||
permissionBatch.isEmpty
|
||||
? Container()
|
||||
: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).secondaryHeaderColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(child: Text(permissionBatch)),
|
||||
),
|
||||
membershipBatch[user.membership].isEmpty
|
||||
? Container()
|
||||
: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).secondaryHeaderColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(child: Text(membershipBatch[user.membership])),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(user.id),
|
||||
leading: Avatar(user.avatarUrl, user.calcDisplayname()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class PermissionsListTile extends StatelessWidget {
|
||||
final String permissionKey;
|
||||
final int permission;
|
||||
final String category;
|
||||
final void Function() onTap;
|
||||
|
||||
const PermissionsListTile({
|
||||
Key key,
|
||||
@required this.permissionKey,
|
||||
@required this.permission,
|
||||
this.category,
|
||||
this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
String getLocalizedPowerLevelString(BuildContext context) {
|
||||
if (category == null) {
|
||||
switch (permissionKey) {
|
||||
case 'users_default':
|
||||
return L10n.of(context).defaultPermissionLevel;
|
||||
case 'events_default':
|
||||
return L10n.of(context).sendMessages;
|
||||
case 'state_default':
|
||||
return L10n.of(context).configureChat;
|
||||
case 'ban':
|
||||
return L10n.of(context).banFromChat;
|
||||
case 'kick':
|
||||
return L10n.of(context).kickFromChat;
|
||||
case 'redact':
|
||||
return L10n.of(context).deleteMessage;
|
||||
case 'invite':
|
||||
return L10n.of(context).inviteContact;
|
||||
}
|
||||
} else if (category == 'notifications') {
|
||||
switch (permissionKey) {
|
||||
case 'rooms':
|
||||
return L10n.of(context).notifications;
|
||||
}
|
||||
} else if (category == 'events') {
|
||||
switch (permissionKey) {
|
||||
case EventTypes.RoomName:
|
||||
return L10n.of(context).changeTheNameOfTheGroup;
|
||||
case EventTypes.RoomPowerLevels:
|
||||
return L10n.of(context).editChatPermissions;
|
||||
case EventTypes.HistoryVisibility:
|
||||
return L10n.of(context).visibilityOfTheChatHistory;
|
||||
case EventTypes.RoomCanonicalAlias:
|
||||
return L10n.of(context).setInvitationLink;
|
||||
case EventTypes.RoomAvatar:
|
||||
return L10n.of(context).editRoomAvatar;
|
||||
case EventTypes.RoomTombstone:
|
||||
return L10n.of(context).replaceRoomWithNewerVersion;
|
||||
case EventTypes.Encryption:
|
||||
return L10n.of(context).enableEncryption;
|
||||
case 'm.room.server_acl':
|
||||
return L10n.of(context).editBlockedServers;
|
||||
}
|
||||
}
|
||||
return permissionKey;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
foregroundColor: Colors.grey,
|
||||
child: const Icon(Icons.edit_attributes_outlined),
|
||||
),
|
||||
title: Text(getLocalizedPowerLevelString(context)),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).secondaryHeaderColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(permission.toString()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(permission.toLocalizedPowerLevelString(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on int {
|
||||
String toLocalizedPowerLevelString(BuildContext context) {
|
||||
return this == 100
|
||||
? L10n.of(context).admin
|
||||
: this >= 50
|
||||
? L10n.of(context).moderator
|
||||
: L10n.of(context).participant;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import '../avatar.dart';
|
||||
import '../matrix.dart';
|
||||
|
||||
class PublicRoomListItem extends StatelessWidget {
|
||||
final PublicRoomsChunk publicRoomEntry;
|
||||
|
||||
const PublicRoomListItem(this.publicRoomEntry, {Key key}) : super(key: key);
|
||||
|
||||
void joinAction(BuildContext context) async {
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => _joinRoomAndWait(context),
|
||||
);
|
||||
if (success.error == null) {
|
||||
VRouter.of(context).toSegments(['rooms', success.result]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _joinRoomAndWait(BuildContext context) async {
|
||||
final roomId =
|
||||
await Matrix.of(context).client.joinRoom(publicRoomEntry.roomId);
|
||||
if (Matrix.of(context).client.getRoomById(roomId) == null) {
|
||||
await Matrix.of(context)
|
||||
.client
|
||||
.onSync
|
||||
.stream
|
||||
.firstWhere((u) => u.rooms?.join?.containsKey(roomId) ?? false);
|
||||
}
|
||||
return roomId;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasTopic =
|
||||
publicRoomEntry.topic != null && publicRoomEntry.topic.isNotEmpty;
|
||||
return ListTile(
|
||||
leading: Avatar(publicRoomEntry.avatarUrl, publicRoomEntry.name),
|
||||
title: Text(hasTopic
|
||||
? '${publicRoomEntry.name} (${publicRoomEntry.numJoinedMembers})'
|
||||
: publicRoomEntry.name),
|
||||
subtitle: Text(
|
||||
hasTopic
|
||||
? publicRoomEntry.topic
|
||||
: publicRoomEntry.numJoinedMembers == null
|
||||
? L10n.of(context).joinRoom
|
||||
: L10n.of(context).countParticipants(
|
||||
publicRoomEntry.numJoinedMembers.toString()),
|
||||
maxLines: 1,
|
||||
),
|
||||
onTap: () => joinAction(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
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 '../../utils/date_time_extension.dart';
|
||||
import '../../utils/matrix_sdk_extensions.dart/device_extension.dart';
|
||||
import '../matrix.dart';
|
||||
|
||||
enum UserDeviceListItemAction {
|
||||
rename,
|
||||
remove,
|
||||
verify,
|
||||
block,
|
||||
unblock,
|
||||
}
|
||||
|
||||
class UserDeviceListItem extends StatelessWidget {
|
||||
final Device userDevice;
|
||||
final void Function(Device) remove;
|
||||
final void Function(Device) rename;
|
||||
final void Function(Device) verify;
|
||||
final void Function(Device) block;
|
||||
final void Function(Device) unblock;
|
||||
|
||||
const UserDeviceListItem(
|
||||
this.userDevice, {
|
||||
@required this.remove,
|
||||
@required this.rename,
|
||||
@required this.verify,
|
||||
@required this.block,
|
||||
@required this.unblock,
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keys = Matrix.of(context)
|
||||
.client
|
||||
.userDeviceKeys[Matrix.of(context).client.userID]
|
||||
?.deviceKeys[userDevice.deviceId];
|
||||
|
||||
return ListTile(
|
||||
onTap: () async {
|
||||
final action = await showModalActionSheet<UserDeviceListItemAction>(
|
||||
context: context,
|
||||
actions: [
|
||||
SheetAction(
|
||||
key: UserDeviceListItemAction.rename,
|
||||
label: L10n.of(context).changeDeviceName,
|
||||
),
|
||||
if (keys != null) ...{
|
||||
SheetAction(
|
||||
key: UserDeviceListItemAction.verify,
|
||||
label: L10n.of(context).verifyStart,
|
||||
),
|
||||
if (!keys.blocked)
|
||||
SheetAction(
|
||||
key: UserDeviceListItemAction.block,
|
||||
label: L10n.of(context).blockDevice,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
if (keys.blocked)
|
||||
SheetAction(
|
||||
key: UserDeviceListItemAction.unblock,
|
||||
label: L10n.of(context).unblockDevice,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
},
|
||||
SheetAction(
|
||||
key: UserDeviceListItemAction.remove,
|
||||
label: L10n.of(context).delete,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
switch (action) {
|
||||
case UserDeviceListItemAction.rename:
|
||||
rename(userDevice);
|
||||
break;
|
||||
case UserDeviceListItemAction.remove:
|
||||
remove(userDevice);
|
||||
break;
|
||||
case UserDeviceListItemAction.verify:
|
||||
verify(userDevice);
|
||||
break;
|
||||
case UserDeviceListItemAction.block:
|
||||
block(userDevice);
|
||||
break;
|
||||
case UserDeviceListItemAction.unblock:
|
||||
unblock(userDevice);
|
||||
break;
|
||||
}
|
||||
},
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: keys == null
|
||||
? Colors.grey[700]
|
||||
: keys.blocked
|
||||
? Colors.red
|
||||
: keys.verified
|
||||
? Colors.green
|
||||
: Colors.orange,
|
||||
child: Icon(userDevice.icon),
|
||||
),
|
||||
title: Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
userDevice.displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Spacer(),
|
||||
if (userDevice.lastSeenTs != null)
|
||||
Text(DateTime.fromMillisecondsSinceEpoch(userDevice.lastSeenTs)
|
||||
.localizedTimeShort(context)),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
userDevice.deviceId,
|
||||
style: const TextStyle(fontWeight: FontWeight.w300),
|
||||
),
|
||||
const Spacer(),
|
||||
if (keys != null)
|
||||
Text(
|
||||
keys.blocked
|
||||
? L10n.of(context).blocked
|
||||
: keys.verified
|
||||
? L10n.of(context).verified
|
||||
: L10n.of(context).unverified,
|
||||
style: TextStyle(
|
||||
color: keys.blocked
|
||||
? Colors.red
|
||||
: keys.verified
|
||||
? Colors.green
|
||||
: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ import 'package:fluffychat/utils/sentry_controller.dart';
|
|||
import 'package:fluffychat/utils/uia_request_manager.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../config/setting_keys.dart';
|
||||
import '../pages/key_verification_dialog.dart';
|
||||
import '../pages/key_verification/key_verification_dialog.dart';
|
||||
import '../utils/account_bundles.dart';
|
||||
import '../utils/background_push.dart';
|
||||
import '../utils/famedlysdk_store.dart';
|
||||
|
|
|
|||
99
lib/widgets/permission_slider_dialog.dart
Normal file
99
lib/widgets/permission_slider_dialog.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/widgets/adaptive_flat_button.dart';
|
||||
|
||||
class PermissionSliderDialog extends StatefulWidget {
|
||||
const PermissionSliderDialog({
|
||||
Key key,
|
||||
this.initialPermission = 0,
|
||||
}) : super(key: key);
|
||||
|
||||
Future<int> show(BuildContext context) => PlatformInfos.isCupertinoStyle
|
||||
? showCupertinoDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => this,
|
||||
useRootNavigator: false,
|
||||
)
|
||||
: showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => this,
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
final int initialPermission;
|
||||
@override
|
||||
_PermissionSliderDialogState createState() => _PermissionSliderDialogState();
|
||||
}
|
||||
|
||||
class _PermissionSliderDialogState extends State<PermissionSliderDialog> {
|
||||
int _permission;
|
||||
@override
|
||||
void initState() {
|
||||
_permission = widget.initialPermission;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final slider = PlatformInfos.isCupertinoStyle
|
||||
? CupertinoSlider(
|
||||
value: _permission.toDouble(),
|
||||
onChanged: (d) => setState(() => _permission = d.round()),
|
||||
max: 100.0,
|
||||
min: 0.0,
|
||||
)
|
||||
: Slider(
|
||||
value: _permission.toDouble(),
|
||||
onChanged: (d) => setState(() => _permission = d.round()),
|
||||
max: 100.0,
|
||||
min: 0.0,
|
||||
);
|
||||
final title = Text(
|
||||
L10n.of(context).setPermissionsLevel,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
final content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Level: ' +
|
||||
(_permission == 100
|
||||
? '$_permission (${L10n.of(context).admin})'
|
||||
: _permission >= 50
|
||||
? '$_permission (${L10n.of(context).moderator})'
|
||||
: _permission.toString())),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: slider,
|
||||
),
|
||||
],
|
||||
);
|
||||
final buttons = [
|
||||
AdaptiveFlatButton(
|
||||
label: L10n.of(context).cancel,
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: false).pop<int>(null),
|
||||
),
|
||||
AdaptiveFlatButton(
|
||||
label: L10n.of(context).confirm,
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: false).pop<int>(_permission),
|
||||
),
|
||||
];
|
||||
if (PlatformInfos.isCupertinoStyle) {
|
||||
return CupertinoAlertDialog(
|
||||
title: title,
|
||||
content: content,
|
||||
actions: buttons,
|
||||
);
|
||||
}
|
||||
return AlertDialog(
|
||||
title: title,
|
||||
content: content,
|
||||
actions: buttons,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue