refactor: Move widgets to lib

This commit is contained in:
Christian Pauly 2021-05-22 08:53:52 +02:00
commit c8827ae1b2
86 changed files with 136 additions and 136 deletions

View file

@ -0,0 +1,34 @@
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class AdaptiveFlatButton extends StatelessWidget {
final String label;
final Color textColor;
final Function onPressed;
const AdaptiveFlatButton({
Key key,
@required this.label,
this.textColor,
this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (PlatformInfos.isCupertinoStyle) {
return CupertinoDialogAction(
onPressed: onPressed,
textStyle: textColor != null ? TextStyle(color: textColor) : null,
child: Text(label),
);
}
return TextButton(
onPressed: onPressed,
child: Text(
label,
style: TextStyle(color: textColor),
),
);
}
}

View file

@ -0,0 +1,210 @@
import 'dart:async';
import 'dart:io';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/sentry_controller.dart';
import 'package:fluffychat/widgets/message_download_content.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html;
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../utils/ui_fake.dart' if (dart.library.html) 'dart:ui' as ui;
import 'matrix.dart';
import '../utils/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 = '00:00';
double currentPosition = 0;
double maxPosition = 0;
File audioFile;
String webSrcUrl;
@override
void initState() {
super.initState();
if (kIsWeb) {
ui.platformViewRegistry.registerViewFactory(
'web_audio_player',
(int viewId) => html.AudioElement()
..src = webSrcUrl
..autoplay = false
..controls = true
..style.border = 'none');
}
}
@override
void dispose() {
if (audioPlayer.state == AudioPlayerState.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 = matrixFile.name.contains('.')
? matrixFile.name
: '${matrixFile.name}.mp3';
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);
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(
content: Text(e.toLocalizedString(context)),
),
);
}
}
void _playAction() async {
if (AudioPlayerWidget.currentId != widget.event.eventId) {
if (AudioPlayerWidget.currentId != null) {
if (audioPlayer.state != AudioPlayerState.STOPPED) {
await audioPlayer.stop();
setState(() => null);
}
}
AudioPlayerWidget.currentId = widget.event.eventId;
}
switch (audioPlayer.state) {
case AudioPlayerState.PLAYING:
await audioPlayer.pause();
break;
case AudioPlayerState.PAUSED:
await audioPlayer.resume();
break;
case AudioPlayerState.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) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).oopsSomethingWentWrong),
),
);
SentryController.captureException(e, StackTrace.current);
});
await audioPlayer.play(audioFile.path);
break;
}
}
@override
Widget build(BuildContext context) {
if (kIsWeb) {
if (widget.event.content['url'] is String) {
webSrcUrl = Uri.parse(widget.event.content['url'])
.getDownloadLink(Matrix.of(context).client)
.toString();
return Container(
height: 50,
width: 300,
child: HtmlElementView(viewType: 'web_audio_player'),
);
}
return MessageDownloadContent(widget.event, widget.color);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 30,
child: status == AudioPlayerStatus.downloading
? CircularProgressIndicator(strokeWidth: 2)
: IconButton(
icon: Icon(
audioPlayer.state == AudioPlayerState.PLAYING
? Icons.pause_outlined
: Icons.play_arrow_outlined,
color: widget.color,
),
tooltip: audioPlayer.state == AudioPlayerState.PLAYING
? L10n.of(context).audioPlayerPause
: L10n.of(context).audioPlayerPlay,
onPressed: () {
if (status == AudioPlayerStatus.downloaded) {
_playAction();
} else {
_downloadAction();
}
},
),
),
Expanded(
child: Slider(
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,
),
),
],
);
}
}

88
lib/widgets/avatar.dart Normal file
View file

@ -0,0 +1,88 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'matrix.dart';
class Avatar extends StatelessWidget {
final Uri mxContent;
final String name;
final double size;
final Function onTap;
static const double defaultSize = 44;
final Client client;
const Avatar(
this.mxContent,
this.name, {
this.size = defaultSize,
this.onTap,
this.client,
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final src = mxContent?.getThumbnail(
client ?? Matrix.of(context).client,
width: size * MediaQuery.of(context).devicePixelRatio,
height: size * MediaQuery.of(context).devicePixelRatio,
);
var fallbackLetters = '@';
if ((name?.runes?.length ?? 0) >= 2) {
fallbackLetters = String.fromCharCodes(name.runes, 0, 2);
} else if ((name?.runes?.length ?? 0) == 1) {
fallbackLetters = name;
}
final textWidget = Center(
child: Text(
fallbackLetters,
style: TextStyle(
color: Theme.of(context).brightness == Brightness.light
? name?.darkColor
: name?.lightColor ?? Colors.white,
fontSize: 18,
),
),
);
final noPic = mxContent == null || mxContent.toString().isEmpty;
final borderRadius = BorderRadius.circular(size / 2);
return InkWell(
onTap: onTap,
borderRadius: borderRadius,
child: ClipRRect(
borderRadius: borderRadius,
child: Container(
width: size,
height: size,
color: noPic
? Theme.of(context).brightness == Brightness.light
? name?.lightColor
: name?.darkColor ?? Theme.of(context).secondaryHeaderColor
: Theme.of(context).secondaryHeaderColor,
child: noPic
? textWidget
: CachedNetworkImage(
imageUrl: src.toString(),
fit: BoxFit.cover,
width: size,
height: size,
placeholder: (c, s) => Stack(
children: [
Center(child: CircularProgressIndicator(strokeWidth: 2)),
textWidget,
],
),
errorWidget: (c, s, d) => Stack(
children: [
textWidget,
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,110 @@
import 'dart:async';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'matrix.dart';
class ChatSettingsPopupMenu extends StatefulWidget {
final Room room;
final bool displayChatDetails;
const ChatSettingsPopupMenu(this.room, this.displayChatDetails, {Key key})
: super(key: key);
@override
_ChatSettingsPopupMenuState createState() => _ChatSettingsPopupMenuState();
}
class _ChatSettingsPopupMenuState extends State<ChatSettingsPopupMenu> {
StreamSubscription notificationChangeSub;
@override
void dispose() {
notificationChangeSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
notificationChangeSub ??= Matrix.of(context)
.client
.onAccountData
.stream
.where((u) => u.type == 'm.push_rules')
.listen(
(u) => setState(() => null),
);
final items = <PopupMenuEntry<String>>[
widget.room.pushRuleState == PushRuleState.notify
? PopupMenuItem<String>(
value: 'mute',
child: Text(L10n.of(context).muteChat),
)
: PopupMenuItem<String>(
value: 'unmute',
child: Text(L10n.of(context).unmuteChat),
),
PopupMenuItem<String>(
value: 'leave',
child: Text(L10n.of(context).leave),
),
];
if (widget.displayChatDetails) {
items.insert(
0,
PopupMenuItem<String>(
value: 'details',
child: Text(L10n.of(context).chatDetails),
),
);
}
return PopupMenuButton(
onSelected: (String choice) async {
switch (choice) {
case 'leave':
final confirmed = await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
);
if (confirmed == OkCancelResult.ok) {
final success = await showFutureLoadingDialog(
context: context, future: () => widget.room.leave());
if (success.error == null) {
await AdaptivePageLayout.of(context)
.pushNamedAndRemoveAllOthers('/');
}
}
break;
case 'mute':
await showFutureLoadingDialog(
context: context,
future: () =>
widget.room.setPushRuleState(PushRuleState.mentionsOnly));
break;
case 'unmute':
await showFutureLoadingDialog(
context: context,
future: () =>
widget.room.setPushRuleState(PushRuleState.notify));
break;
case 'details':
if (!AdaptivePageLayout.of(context).columnMode(context) ||
AdaptivePageLayout.of(context).viewDataStack.length < 3) {
await AdaptivePageLayout.of(context)
.pushNamed('/rooms/${widget.room.id}/details');
}
break;
}
},
itemBuilder: (BuildContext context) => items,
);
}
}

View file

@ -0,0 +1,44 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'matrix.dart';
class ConnectionStatusHeader extends StatefulWidget {
@override
_ConnectionStatusHeaderState createState() => _ConnectionStatusHeaderState();
}
class _ConnectionStatusHeaderState extends State<ConnectionStatusHeader> {
StreamSubscription _onSyncSub;
StreamSubscription _onSyncErrorSub;
static bool _connected = true;
set connected(bool connected) {
if (mounted) {
setState(() => _connected = connected);
}
}
@override
void dispose() {
_onSyncSub?.cancel();
_onSyncErrorSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
_onSyncSub ??= Matrix.of(context).client.onSync.stream.listen(
(_) => connected = true,
);
_onSyncErrorSub ??= Matrix.of(context).client.onSyncError.stream.listen(
(_) => connected = false,
);
return AnimatedContainer(
duration: Duration(milliseconds: 300),
height: _connected ? 0 : 5,
child: LinearProgressIndicator(),
);
}
}

View file

@ -0,0 +1,111 @@
import 'dart:async';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import '../utils/client_presence_extension.dart';
import '../utils/presence_extension.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
class ContactsList extends StatefulWidget {
final TextEditingController searchController;
const ContactsList({
Key key,
@required this.searchController,
}) : super(key: key);
@override
_ContactsState createState() => _ContactsState();
}
class _ContactsState extends State<ContactsList> {
StreamSubscription _onSync;
@override
void dispose() {
_onSync?.cancel();
super.dispose();
}
DateTime _lastSetState = DateTime.now();
Timer _coolDown;
void _updateView() {
_lastSetState = DateTime.now();
setState(() => null);
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
_onSync ??= client.onSync.stream.listen((_) {
if (DateTime.now().millisecondsSinceEpoch -
_lastSetState.millisecondsSinceEpoch <
1000) {
_coolDown?.cancel();
_coolDown = Timer(Duration(seconds: 1), _updateView);
} else {
_updateView();
}
});
final contactList = Matrix.of(context)
.client
.contactList
.where((p) => p.senderId
.toLowerCase()
.contains(widget.searchController.text.toLowerCase()))
.toList();
return ListView.builder(
itemCount: contactList.length,
itemBuilder: (_, i) => _ContactListTile(contact: contactList[i]),
);
}
}
class _ContactListTile extends StatelessWidget {
final Presence contact;
const _ContactListTile({Key key, @required this.contact}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder<Profile>(
future:
Matrix.of(context).client.getProfileFromUserId(contact.senderId),
builder: (context, snapshot) {
final displayname =
snapshot.data?.displayname ?? contact.senderId.localpart;
final avatarUrl = snapshot.data?.avatarUrl;
return ListTile(
leading: Container(
width: Avatar.defaultSize,
height: Avatar.defaultSize,
child: Stack(
children: [
Center(child: Avatar(avatarUrl, displayname)),
Align(
alignment: Alignment.bottomRight,
child: Icon(
Icons.circle,
color: contact.color,
size: 12,
),
),
],
),
),
title: Text(displayname),
subtitle: Text(contact.getLocalizedStatusMessage(context),
style: contact.presence.statusMsg?.isNotEmpty ?? false
? TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
)
: null),
onTap: () => AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst(
'/rooms/${Matrix.of(context).client.getDirectChatFromUserId(contact.senderId)}'),
);
});
}
}

View file

@ -0,0 +1,77 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'matrix.dart';
class ContentBanner extends StatelessWidget {
final Uri mxContent;
final double height;
final IconData defaultIcon;
final bool loading;
final Function onEdit;
final Client client;
final double opacity;
const ContentBanner(this.mxContent,
{this.height = 400,
this.defaultIcon = Icons.people_outline,
this.loading = false,
this.onEdit,
this.client,
this.opacity = 0.75,
Key key})
: super(key: key);
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final bannerSize =
(mediaQuery.size.width * mediaQuery.devicePixelRatio).toInt();
final src = mxContent?.getThumbnail(
client ?? Matrix.of(context).client,
width: bannerSize,
height: bannerSize,
method: ThumbnailMethod.scale,
animated: true,
);
return Container(
height: height,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(context).secondaryHeaderColor,
),
child: Stack(
children: <Widget>[
Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: Opacity(
opacity: opacity,
child: (!loading && mxContent != null)
? CachedNetworkImage(
imageUrl: src.toString(),
height: 300,
fit: BoxFit.cover,
)
: Icon(defaultIcon, size: 200),
),
),
if (onEdit != null)
Container(
margin: EdgeInsets.all(8),
alignment: Alignment.bottomRight,
child: FloatingActionButton(
mini: true,
onPressed: onEdit,
child: Icon(Icons.camera_alt_outlined),
),
),
],
),
);
}
}

View file

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../config/app_config.dart';
class DefaultAppBarSearchField extends StatefulWidget {
final TextEditingController searchController;
final void Function(String) onChanged;
final void Function(String) onSubmit;
final Widget suffix;
final bool autofocus;
final String prefixText;
final String hintText;
final EdgeInsets padding;
final bool readOnly;
final Widget prefixIcon;
final bool unfocusOnClear;
DefaultAppBarSearchField({
Key key,
this.searchController,
this.onChanged,
this.onSubmit,
this.suffix,
this.autofocus = false,
this.prefixText,
this.hintText,
this.padding,
this.readOnly = false,
this.prefixIcon,
this.unfocusOnClear = true,
}) : super(key: key);
@override
DefaultAppBarSearchFieldState createState() =>
DefaultAppBarSearchFieldState();
}
class DefaultAppBarSearchFieldState extends State<DefaultAppBarSearchField> {
TextEditingController _searchController;
bool _lastTextWasEmpty = false;
final FocusNode _focusNode = FocusNode();
void requestFocus() => _focusNode.requestFocus();
void _updateSearchController() {
final thisTextIsEmpty = _searchController.text?.isEmpty ?? false;
if (_lastTextWasEmpty != thisTextIsEmpty) {
setState(() => _lastTextWasEmpty = thisTextIsEmpty);
}
}
@override
void initState() {
super.initState();
_searchController = widget.searchController ?? TextEditingController();
// we need to remove the listener in the dispose method, so we need a reference to the callback
_searchController.addListener(_updateSearchController);
_focusNode.addListener(() => setState(() => null));
}
@override
void dispose() {
_focusNode.dispose();
_searchController.removeListener(_updateSearchController);
if (widget.searchController == null) {
// we need to dispose our own created searchController
_searchController.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: 40,
padding: widget.padding ?? EdgeInsets.only(right: 12),
child: TextField(
autofocus: widget.autofocus,
autocorrect: false,
controller: _searchController,
onChanged: widget.onChanged,
focusNode: _focusNode,
readOnly: widget.readOnly,
onSubmitted: widget.onSubmit,
decoration: InputDecoration(
prefixText: widget.prefixText,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
borderSide:
BorderSide(color: Theme.of(context).secondaryHeaderColor),
),
contentPadding: EdgeInsets.only(
top: 8,
bottom: 8,
left: 16,
),
hintText: widget.hintText,
prefixIcon: widget.prefixIcon,
suffixIcon: !widget.readOnly &&
(_focusNode.hasFocus ||
(widget.suffix == null &&
(_searchController.text?.isNotEmpty ?? false)))
? IconButton(
tooltip: L10n.of(context).clearText,
icon: Icon(Icons.backspace_outlined),
onPressed: () {
_searchController.clear();
widget.onChanged?.call('');
if (widget.unfocusOnClear) _focusNode.unfocus();
},
)
: widget.suffix,
),
),
);
}
}

View file

@ -0,0 +1,115 @@
import 'dart:async';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.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) {
await AdaptivePageLayout.of(context)
.pushNamed('/rooms/${widget.room.id}/encryption');
return;
}
if (widget.room.joinRules == JoinRules.public) {
await showOkAlertDialog(
context: context,
useRootNavigator: false,
okLabel: L10n.of(context).ok,
message: L10n.of(context).noEncryptionForPublicRooms,
);
return;
}
if (await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
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 = null;
}
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,
);
});
}
}

View file

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class FluffyBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Image.asset(Theme.of(context).brightness == Brightness.dark
? 'assets/banner_dark.png'
: 'assets/banner.png');
}
}

View file

@ -0,0 +1,133 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter_matrix_html/flutter_html.dart';
import 'package:flutter/material.dart';
import '../utils/url_launcher.dart';
import '../config/setting_keys.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(
{this.html,
this.maxLines,
this.room,
this.defaultTextStyle,
this.linkStyle,
this.emoteSize});
@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.accentColor,
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: animated,
)
.toString();
},
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': identifier,
'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.canonicalAlias,
'avatar_url': r.getState('m.room.avatar')?.content['url'],
};
}
return null;
},
);
}
}

View file

@ -0,0 +1,273 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/image_viewer.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../utils/event_extension.dart';
class ImageBubble extends StatefulWidget {
final Event event;
final bool tapToView;
final BoxFit fit;
final bool maxSize;
final Color backgroundColor;
final double radius;
final bool thumbnailOnly;
final void Function() onLoaded;
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,
Key key,
}) : super(key: key);
@override
_ImageBubbleState createState() => _ImageBubbleState();
}
class _ImageBubbleState extends State<ImageBubble> {
String thumbnailUrl;
String attachmentUrl;
MatrixFile _file;
MatrixFile _thumbnail;
bool _requestedThumbnailOnFailure = false;
bool get isSvg =>
widget.event.attachmentMimetype.split('+').first == 'image/svg';
bool get isThumbnailSvg =>
widget.event.thumbnailMimetype.split('+').first == 'image/svg';
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);
}
});
}
}
@override
void initState() {
thumbnailUrl = widget.event
.getAttachmentUrl(getThumbnail: true, animated: true)
?.toString();
attachmentUrl = widget.event.getAttachmentUrl(animated: true)?.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() {
return Center(
child: Text(
_error.toString(),
),
);
}
Widget getPlaceholderWidget() {
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: CircularProgressIndicator(),
),
],
);
}
Widget getMemoryWidget() {
final isOriginal = _file != null ||
widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) ==
widget.event.attachmentMxcUrl;
final key = isOriginal
? widget.event.attachmentMxcUrl
: widget.event.thumbnailMxcUrl;
if (isOriginal ? isSvg : isThumbnailSvg) {
return SvgPicture.memory(
_displayFile.bytes,
key: ValueKey(key),
fit: widget.fit,
);
} else {
return Image.memory(
_displayFile.bytes,
key: ValueKey(key),
fit: widget.fit,
);
}
}
Widget getNetworkWidget() {
if (displayUrl == attachmentUrl &&
(_requestedThumbnailOnFailure ? isSvg : isThumbnailSvg)) {
return SvgPicture.network(
displayUrl,
key: ValueKey(displayUrl),
placeholderBuilder: (context) => getPlaceholderWidget(),
fit: widget.fit,
);
} 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(),
fit: widget.fit,
);
}
return getPlaceholderWidget();
},
errorWidget: (context, url, error) {
// we can re-request the thumbnail
if (!_requestedThumbnailOnFailure) {
_requestedThumbnailOnFailure = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
thumbnailUrl = widget.event
.getAttachmentUrl(
getThumbnail: true,
useThumbnailMxcUrl: true,
animated: true)
?.toString();
attachmentUrl = widget.event
.getAttachmentUrl(useThumbnailMxcUrl: true, animated: true)
?.toString();
});
});
}
return getPlaceholderWidget();
},
fit: widget.fit,
);
}
}
@override
Widget build(BuildContext context) {
Widget content;
String key;
if (_error != null) {
content = getErrorWidget();
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';
}
return ClipRRect(
borderRadius: BorderRadius.circular(widget.radius),
child: InkWell(
onTap: () {
if (!widget.tapToView) return;
Navigator.of(context, rootNavigator: false).push(
MaterialPageRoute(
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();
}
});
}
}),
),
);
},
child: Hero(
tag: widget.event.eventId,
child: AnimatedSwitcher(
duration: Duration(milliseconds: 1000),
child: Container(
key: ValueKey(key),
height: widget.maxSize ? 300 : null,
width: widget.maxSize ? 400 : null,
child: content,
),
),
),
),
);
}
}

305
lib/widgets/input_bar.dart Normal file
View file

@ -0,0 +1,305 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'avatar.dart';
import 'matrix.dart';
class InputBar extends StatelessWidget {
final Room room;
final int minLines;
final int maxLines;
final TextInputType keyboardType;
final ValueChanged<String> onSubmitted;
final FocusNode focusNode;
final TextEditingController controller;
final InputDecoration decoration;
final ValueChanged<String> onChanged;
final bool autofocus;
InputBar({
this.room,
this.minLines,
this.maxLines,
this.keyboardType,
this.onSubmitted,
this.focusNode,
this.controller,
this.decoration,
this.onChanged,
this.autofocus,
});
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>>[];
final emojiMatch =
RegExp(r'(?:\s|^):(?:([-\w]+)~)?([-\w]+)$').firstMatch(searchText);
final MAX_RESULTS = 10;
if (emojiMatch != null) {
final packSearch = emojiMatch[1];
final emoteSearch = emojiMatch[2].toLowerCase();
final emotePacks = room.emotePacks;
if (packSearch == null || packSearch.isEmpty) {
for (final pack in emotePacks.entries) {
for (final emote in pack.value.entries) {
if (emote.key.toLowerCase().contains(emoteSearch)) {
ret.add({
'type': 'emote',
'name': emote.key,
'pack': pack.key,
'mxc': emote.value,
});
}
if (ret.length > MAX_RESULTS) {
break;
}
}
if (ret.length > MAX_RESULTS) {
break;
}
}
} else if (emotePacks[packSearch] != null) {
for (final emote in emotePacks[packSearch].entries) {
if (emote.key.toLowerCase().contains(emoteSearch)) {
ret.add({
'type': 'emote',
'name': emote.key,
'pack': packSearch,
'mxc': emote.value,
});
}
if (ret.length > MAX_RESULTS) {
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)) ||
user.id.split(':')[0].toLowerCase().contains(userSearch)) {
ret.add({
'type': 'user',
'mxid': user.id,
'displayname': user.displayName,
'avatar_url': user.avatarUrl?.toString(),
});
}
if (ret.length > MAX_RESULTS) {
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 > MAX_RESULTS) {
break;
}
}
}
return ret;
}
Widget buildSuggestion(
BuildContext context,
Map<String, String> suggestion,
Client client,
) {
if (suggestion['type'] == 'emote') {
final size = 30.0;
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: EdgeInsets.all(4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
CachedNetworkImage(
imageUrl: url.toString(),
width: size,
height: size,
),
SizedBox(width: 6),
Text(suggestion['name']),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Opacity(
opacity: 0.5,
child: Text(suggestion['pack']),
),
),
),
],
),
);
}
if (suggestion['type'] == 'user' || suggestion['type'] == 'room') {
final size = 30.0;
final url = Uri.parse(suggestion['avatar_url'] ?? '');
return Container(
padding: EdgeInsets.all(4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Avatar(
url,
suggestion['displayname'] ?? suggestion['mxid'],
size: size,
client: client,
),
SizedBox(width: 6),
Text(suggestion['displayname'] ?? suggestion['mxid']),
],
),
);
}
return Container();
}
void insertSuggestion(BuildContext context, 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'] == 'emote') {
var isUnique = true;
final insertEmote = suggestion['name'];
final insertPack = suggestion['pack'];
final emotePacks = room.emotePacks;
for (final pack in emotePacks.entries) {
if (pack.key == insertPack) {
continue;
}
for (final emote in pack.value.entries) {
if (emote.key == insertEmote) {
isUnique = false;
break;
}
}
if (!isUnique) {
break;
}
}
insertText = (isUnique
? insertEmote
: ':$insertPack~${insertEmote.substring(1)}') +
' ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(:(?:[-\w]+~)?[-\w]+)$'),
(Match m) => '${m[1]}$insertText',
);
}
if (suggestion['type'] == 'user') {
insertText = suggestion['mxid'] + ' ';
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) {
return TypeAheadField<Map<String, String>>(
direction: AxisDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
keepSuggestionsOnSuggestionSelected: true,
debounceDuration: Duration(
milliseconds:
50), // show suggestions after 50ms idle time (default is 300)
textFieldConfiguration: TextFieldConfiguration(
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType,
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,
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
);
}
}

View file

@ -0,0 +1,14 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class LoadingView extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (Matrix.of(context).loginState != null) {
WidgetsBinding.instance.addPostFrameCallback((_) =>
AdaptivePageLayout.of(context).pushNamedAndRemoveAllOthers('/'));
}
return Scaffold(body: Center(child: CircularProgressIndicator()));
}
}

View file

@ -0,0 +1,38 @@
import 'dart:math';
import 'package:flutter/material.dart';
class MaxWidthBody extends StatelessWidget {
final Widget child;
final double maxWidth;
final bool withScrolling;
const MaxWidthBody({
this.child,
this.maxWidth = 600,
this.withScrolling = false,
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final padding = EdgeInsets.symmetric(
horizontal: max(0, (constraints.maxWidth - maxWidth) / 2),
);
return withScrolling
? SingleChildScrollView(
physics: ScrollPhysics(),
child: Padding(
padding: padding,
child: child,
),
)
: Padding(
padding: padding,
child: child,
);
},
);
}
}

View file

@ -0,0 +1,45 @@
import 'dart:math';
import 'package:flutter/material.dart';
class OnePageCard extends StatelessWidget {
final Widget child;
const OnePageCard({Key key, this.child}) : super(key: key);
static const int alpha = 12;
static const int breakpoint = 600;
@override
Widget build(BuildContext context) {
return MediaQuery.of(context).size.width <= breakpoint ||
MediaQuery.of(context).size.height <= breakpoint
? child
: Container(
decoration: BoxDecoration(
color: Theme.of(context).backgroundColor,
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
stops: [
0.1,
0.4,
0.6,
0.9,
],
colors: [
Theme.of(context).secondaryHeaderColor.withAlpha(alpha),
Theme.of(context).primaryColor.withAlpha(alpha),
Theme.of(context).accentColor.withAlpha(alpha),
Theme.of(context).backgroundColor.withAlpha(alpha),
],
),
),
padding: EdgeInsets.symmetric(
horizontal:
max((MediaQuery.of(context).size.width - 600) / 2, 12),
vertical: max((MediaQuery.of(context).size.height - 800) / 2, 12),
),
child: SafeArea(child: Card(child: child)),
);
}
}

View file

@ -0,0 +1,287 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/utils/event_extension.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/utils/room_status_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:pedantic/pedantic.dart';
import '../../utils/date_time_extension.dart';
import '../avatar.dart';
import '../../views/send_file_dialog.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import '../matrix.dart';
import '../mouse_over_builder.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});
void clickAction(BuildContext context) async {
if (onTap != null) return onTap();
if (!activeChat) {
if (room.membership == Membership.invite &&
(await showFutureLoadingDialog(
context: context, future: () => room.join()))
.error !=
null) {
return;
}
if (room.membership == Membership.ban) {
AdaptivePageLayout.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,
builder: (c) => SendFileDialog(
file: Matrix.of(context).shareContent['file'],
room: room,
),
useRootNavigator: false,
);
} else {
unawaited(room.sendEvent(Matrix.of(context).shareContent));
}
Matrix.of(context).shareContent = null;
}
await AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/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(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).no,
useRootNavigator: false,
);
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;
return Center(
child: Material(
color: FluffyThemes.chatListItemColor(context, activeChat, selected),
child: ListTile(
onLongPress: onLongPress,
leading: MouseOverBuilder(
builder: (context, hover) =>
onLongPress != null && (hover || selected)
? Container(
width: Avatar.defaultSize,
height: Avatar.defaultSize,
alignment: Alignment.center,
child: Checkbox(
value: selected,
onChanged: (_) => onLongPress(),
),
)
: Avatar(room.avatar, room.displayname),
),
title: Row(
children: <Widget>[
Expanded(
child: Text(
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context))),
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
),
),
if (isMuted)
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Icon(
Icons.notifications_off_outlined,
size: 16,
),
),
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(
room.timeCreated.localizedTimeShort(context),
style: TextStyle(
fontSize: 13,
color: room.notificationCount > 0
? Theme.of(context).accentColor
: null,
),
),
),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (typingText.isEmpty && ownMessage) ...{
Icon(
room.lastEvent.statusIcon,
size: 14,
),
SizedBox(width: 4),
},
if (typingText.isNotEmpty) ...{
Icon(
Icons.edit_outlined,
color: Theme.of(context).accentColor,
size: 14,
),
SizedBox(width: 4),
},
Expanded(
child: typingText.isNotEmpty
? Text(
typingText,
style: TextStyle(
color: Theme.of(context).accentColor,
),
softWrap: false,
)
: room.membership == Membership.invite
? Text(
L10n.of(context).youAreInvitedToThisChat,
style: TextStyle(
color: Theme.of(context).accentColor,
),
softWrap: false,
)
: Text(
room.lastEvent?.getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix:
!ownMessage && !room.isDirectChat,
hideReply: true,
) ??
'',
softWrap: false,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
decoration: room.lastEvent?.redacted == true
? TextDecoration.lineThrough
: null,
),
),
),
SizedBox(width: 8),
if (room.isFavourite)
Padding(
padding: EdgeInsets.only(
right: room.notificationCount > 0 ? 4.0 : 0.0),
child: Icon(
Icons.push_pin_outlined,
size: 20,
color: Theme.of(context).accentColor,
),
),
if (room.isUnread)
Container(
padding: EdgeInsets.symmetric(horizontal: 7),
height: room.notificationCount > 0 ? 20 : 14,
decoration: BoxDecoration(
color: room.highlightCount > 0 || room.markedUnread
? Colors.red
: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: room.notificationCount > 0
? Text(
room.notificationCount.toString(),
style: TextStyle(
color: Colors.white,
),
)
: Container(),
),
),
],
),
onTap: () => clickAction(context),
),
),
);
}
}

View file

@ -0,0 +1,284 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/message_content.dart';
import 'package:fluffychat/widgets/reply_content.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/event_extension.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:flutter/material.dart';
import '../../config/app_config.dart';
import '../avatar.dart';
import '../matrix.dart';
import '../message_reactions.dart';
import 'state_message.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});
/// 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);
}
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 = Theme.of(context).scaffoldBackgroundColor;
textColor = Theme.of(context).textTheme.bodyText2.color;
} else if (ownMessage) {
color = displayEvent.status == -1
? 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(
padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
constraints:
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,
roomId: event.roomId,
status: 1,
originServerTs: DateTime.now(),
);
return InkWell(
onTap: () {
if (scrollToEventId != null) {
scrollToEventId(replyEvent.eventId);
}
},
child: AbsorbPointer(
child: Container(
margin: EdgeInsets.symmetric(vertical: 4.0),
child: ReplyContent(replyEvent,
lightText: ownMessage,
timeline: timeline),
),
),
);
},
),
MessageContent(
displayEvent,
textColor: textColor,
),
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
? 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 Container(
color: selected
? Theme.of(context).primaryColor.withAlpha(100)
: Theme.of(context).primaryColor.withAlpha(0),
child: Padding(
padding: 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) 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) SizedBox(width: 2),
if (ownMessage)
Icon(
displayEvent.statusIcon,
size: 14 * AppConfig.fontSizeFactor,
color: color,
),
],
);
}
}

View file

@ -0,0 +1,66 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../avatar.dart';
import '../../views/user_bottom_sheet.dart';
class ParticipantListItem extends StatelessWidget {
final User user;
const ParticipantListItem(this.user);
@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: EdgeInsets.all(4),
margin: 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: EdgeInsets.all(4),
margin: 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()),
);
}
}

View file

@ -0,0 +1,103 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.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: Icon(Icons.edit_attributes_outlined),
),
title: Text(getLocalizedPowerLevelString(context)),
subtitle: Row(
children: [
Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).secondaryHeaderColor,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(permission.toString()),
),
),
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;
}
}

View file

@ -0,0 +1,64 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../avatar.dart';
import '../matrix.dart';
class PublicRoomListItem extends StatelessWidget {
final PublicRoom 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) {
await AdaptivePageLayout.of(context)
.pushNamed('/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
.onRoomUpdate
.stream
.firstWhere((r) => r.id == roomId);
}
return roomId;
}
@override
Widget build(BuildContext context) {
final hasTopic =
publicRoomEntry.topic != null && publicRoomEntry.topic.isNotEmpty;
return ListTile(
leading: Avatar(
publicRoomEntry.avatarUrl == null
? null
: Uri.parse(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),
);
}
}

View file

@ -0,0 +1,67 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.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});
@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,
),
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(
counter == 1
? L10n.of(context).oneMoreEvent
: L10n.of(context).xMoreEvents(counter.toString()),
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,134 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../matrix.dart';
import '../../utils/date_time_extension.dart';
import '../../utils/device_extension.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,
),
SheetAction(
key: UserDeviceListItemAction.verify,
label: L10n.of(context).verifyStart,
),
if (keys != null) ...{
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: Theme.of(context).textTheme.bodyText1.color,
backgroundColor: Theme.of(context).secondaryHeaderColor,
child: Icon(userDevice.icon),
),
title: Row(
children: <Widget>[
Text(
userDevice.displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Spacer(),
Text(userDevice.lastSeenTs.localizedTimeShort(context)),
],
),
subtitle: Row(
children: <Widget>[
Text(userDevice.deviceId),
Spacer(),
if (keys != null)
Text(
keys.blocked
? L10n.of(context).blocked
: keys.verified
? L10n.of(context).verified
: L10n.of(context).unknownDevice,
style: TextStyle(
color: keys.blocked
? Colors.red
: keys.verified
? Colors.green
: Colors.orange,
),
),
],
),
);
}
}

View file

@ -0,0 +1,86 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_lock/flutter_app_lock.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:pin_code_text_field/pin_code_text_field.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'layouts/one_page_card.dart';
class LockScreen extends StatefulWidget {
@override
_LockScreenState createState() => _LockScreenState();
}
class _LockScreenState extends State<LockScreen> {
final TextEditingController _textEditingController = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _wrongInput = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: FluffyThemes.light,
darkTheme: FluffyThemes.light,
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: Builder(
builder: (context) => OnePageCard(
child: Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
elevation: 0,
centerTitle: true,
title: Text(L10n.of(context).pleaseEnterYourPin),
backgroundColor: Colors.transparent,
),
extendBodyBehindAppBar: true,
body: Container(
decoration: BoxDecoration(
color: Theme.of(context).backgroundColor,
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
stops: [
0.1,
0.4,
0.6,
0.9,
],
colors: [
Theme.of(context).secondaryHeaderColor.withAlpha(16),
Theme.of(context).primaryColor.withAlpha(16),
Theme.of(context).accentColor.withAlpha(16),
Theme.of(context).backgroundColor.withAlpha(16),
],
),
),
alignment: Alignment.center,
child: PinCodeTextField(
autofocus: true,
controller: _textEditingController,
focusNode: _focusNode,
pinBoxRadius: AppConfig.borderRadius,
pinTextStyle: TextStyle(fontSize: 32),
hideCharacter: true,
hasError: _wrongInput,
onDone: (String input) async {
if (input ==
await FlutterSecureStorage()
.read(key: SettingKeys.appLockKey)) {
AppLock.of(context).didUnlock();
} else {
_textEditingController.clear();
setState(() => _wrongInput = true);
_focusNode.requestFocus();
}
},
),
),
),
),
),
);
}
}

65
lib/widgets/log_view.dart Normal file
View file

@ -0,0 +1,65 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
class LogViewer extends StatefulWidget {
@override
_LogViewerState createState() => _LogViewerState();
}
class _LogViewerState extends State<LogViewer> {
Level logLevel = Level.debug;
double fontSize = 14;
@override
Widget build(BuildContext context) {
final outputEvents = Logs()
.outputEvents
.where((e) => e.level.index >= logLevel.index)
.toList();
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: Text(logLevel.toString()),
leading: BackButton(),
actions: [
IconButton(
icon: Icon(Icons.zoom_in_outlined),
onPressed: () => setState(() => fontSize++),
),
IconButton(
icon: Icon(Icons.zoom_out_outlined),
onPressed: () => setState(() => fontSize--),
),
PopupMenuButton<Level>(
itemBuilder: (context) => Level.values
.map((level) => PopupMenuItem(
value: level,
child: Text(level.toString()),
))
.toList(),
onSelected: (Level level) => setState(() => logLevel = level),
),
],
),
body: ListView.builder(
itemCount: outputEvents.length,
itemBuilder: (context, i) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(outputEvents[i].toDisplayString()),
),
),
);
}
}
extension on LogEvent {
String toDisplayString() {
var str = '# $title';
if (exception != null) {
str += ' - ${exception.toString()}';
}
if (stackTrace != null) {
str += '\n${stackTrace.toString()}';
}
return str;
}
}

480
lib/widgets/matrix.dart Normal file
View file

@ -0,0 +1,480 @@
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/encryption.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/sentry_controller.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_lock/flutter_app_lock.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:provider/provider.dart';
import 'package:universal_html/html.dart' as html;
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'package:desktop_notifications/desktop_notifications.dart';
import '../utils/beautify_string_extension.dart';
import '../utils/localized_exception_extension.dart';
import '../utils/famedlysdk_store.dart';
import '../views/key_verification_dialog.dart';
import '../utils/platform_infos.dart';
import '../config/app_config.dart';
import '../config/setting_keys.dart';
import '../utils/fluffy_client.dart';
import '../utils/background_push.dart';
class Matrix extends StatefulWidget {
static const String callNamespace = 'chat.fluffy.jitsi_call';
final Widget child;
final GlobalKey<AdaptivePageLayoutState> apl;
final BuildContext context;
final Client testClient;
Matrix({
this.child,
@required this.apl,
@required this.context,
this.testClient,
Key key,
}) : super(key: key);
@override
MatrixState createState() => MatrixState();
/// Returns the (nearest) Client instance of your application.
static MatrixState of(BuildContext context) =>
Provider.of<MatrixState>(context, listen: false);
}
class MatrixState extends State<Matrix> with WidgetsBindingObserver {
FluffyClient client;
Store store = Store();
@override
BuildContext get context => widget.context;
BackgroundPush _backgroundPush;
bool get testMode => widget.testClient != null;
Map<String, dynamic> get shareContent => _shareContent;
set shareContent(Map<String, dynamic> content) {
_shareContent = content;
onShareContentChanged.add(_shareContent);
}
Map<String, dynamic> _shareContent;
final StreamController<Map<String, dynamic>> onShareContentChanged =
StreamController.broadcast();
File wallpaper;
void _initWithStore() async {
try {
if (!testMode) await client.init();
if (client.isLogged()) {
final statusMsg = await store.getItem(SettingKeys.ownStatusMessage);
if (statusMsg?.isNotEmpty ?? false) {
Logs().v('Send cached status message: "$statusMsg"');
await client.setPresence(
client.userID,
PresenceType.online,
statusMsg: statusMsg,
);
}
}
} catch (e, s) {
client.onLoginStateChanged.sink.addError(e, s);
SentryController.captureException(e, s);
rethrow;
}
}
StreamSubscription onRoomKeyRequestSub;
StreamSubscription onKeyVerificationRequestSub;
StreamSubscription onJitsiCallSub;
StreamSubscription onNotification;
StreamSubscription<LoginState> onLoginStateChanged;
StreamSubscription<UiaRequest> onUiaRequest;
StreamSubscription<html.Event> onFocusSub;
StreamSubscription<html.Event> onBlurSub;
StreamSubscription<Presence> onOwnPresence;
String _cachedPassword;
String get cachedPassword {
final tmp = _cachedPassword;
_cachedPassword = null;
return tmp;
}
set cachedPassword(String p) => _cachedPassword = p;
String currentClientSecret;
RequestTokenResponse currentThreepidCreds;
void _onUiaRequest(UiaRequest uiaRequest) async {
try {
if (uiaRequest.state != UiaRequestState.waitForUser ||
uiaRequest.nextStages.isEmpty) return;
final stage = uiaRequest.nextStages.first;
switch (stage) {
case AuthenticationTypes.password:
final input = cachedPassword ??
(await showTextInputDialog(
context: context,
title: L10n.of(context).pleaseEnterYourPassword,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
minLines: 1,
maxLines: 1,
obscureText: true,
hintText: '******',
)
],
))
?.single;
if (input?.isEmpty ?? true) return;
return uiaRequest.completeStage(
AuthenticationPassword(
session: uiaRequest.session,
user: client.userID,
password: input,
identifier: AuthenticationUserIdentifier(user: client.userID),
),
);
case AuthenticationTypes.emailIdentity:
if (currentClientSecret == null || currentThreepidCreds == null) {
return uiaRequest
.cancel(Exception('This server requires an email address'));
}
final auth = AuthenticationThreePidCreds(
session: uiaRequest.session,
type: AuthenticationTypes.emailIdentity,
threepidCreds: [
ThreepidCreds(
sid: currentThreepidCreds.sid,
clientSecret: currentClientSecret,
),
],
);
currentThreepidCreds = currentClientSecret = null;
return uiaRequest.completeStage(auth);
case AuthenticationTypes.dummy:
return uiaRequest.completeStage(
AuthenticationData(
type: AuthenticationTypes.dummy,
session: uiaRequest.session,
),
);
default:
await launch(
client.homeserver.toString() +
'/_matrix/client/r0/auth/$stage/fallback/web?session=${uiaRequest.session}',
);
if (OkCancelResult.ok ==
await showOkCancelAlertDialog(
message: L10n.of(context).pleaseFollowInstructionsOnWeb,
context: context,
useRootNavigator: false,
okLabel: L10n.of(context).next,
cancelLabel: L10n.of(context).cancel,
)) {
return uiaRequest.completeStage(
AuthenticationData(session: uiaRequest.session),
);
} else {
return uiaRequest.cancel();
}
}
} catch (e, s) {
Logs().e('Error while background UIA', e, s);
return uiaRequest.cancel(e);
}
}
bool webHasFocus = true;
void _showLocalNotification(EventUpdate eventUpdate) async {
final roomId = eventUpdate.roomID;
if (webHasFocus && client.activeRoomId == roomId) return;
final room = client.getRoomById(roomId);
if (room.notificationCount == 0) return;
final event = Event.fromJson(eventUpdate.content, room);
final title = room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)));
final body = event.getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix:
!room.isDirectChat || room.lastEvent.senderId == client.userID,
);
final icon = event.sender.avatarUrl?.getThumbnail(client,
width: 64, height: 64, method: ThumbnailMethod.crop) ??
room.avatar?.getThumbnail(client,
width: 64, height: 64, method: ThumbnailMethod.crop);
if (kIsWeb) {
html.AudioElement()
..src = 'assets/assets/sounds/notification.wav'
..autoplay = true
..load();
html.Notification(
title,
body: body,
icon: icon.toString(),
);
} else if (Platform.isLinux) {
await linuxNotifications.notify(
title,
body: body,
replacesId: _linuxNotificationIds[roomId] ?? -1,
appName: AppConfig.applicationName,
);
}
}
final linuxNotifications =
PlatformInfos.isLinux ? NotificationsClient() : null;
final Map<String, int> _linuxNotificationIds = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
initMatrix();
if (PlatformInfos.isWeb) {
initConfig().then((_) => initSettings());
} else {
initSettings();
}
}
Future<void> initConfig() async {
try {
final configJsonString =
utf8.decode((await http.get(Uri.parse('config.json'))).bodyBytes);
final configJson = json.decode(configJsonString);
AppConfig.loadFromJson(configJson);
} catch (e, s) {
Logs().v('[ConfigLoader] Failed to load config.json', e, s);
}
}
LoginState loginState;
void initMatrix() {
// Display the app lock
if (PlatformInfos.isMobile) {
WidgetsBinding.instance.addPostFrameCallback((_) {
FlutterSecureStorage().read(key: SettingKeys.appLockKey).then((lock) {
if (lock?.isNotEmpty ?? false) {
AppLock.of(context).enable();
AppLock.of(context).showLockScreen();
}
});
});
}
client = FluffyClient();
LoadingDialog.defaultTitle = L10n.of(context).loadingPleaseWait;
LoadingDialog.defaultBackLabel = L10n.of(context).close;
LoadingDialog.defaultOnError = (Object e) => e.toLocalizedString(context);
onRoomKeyRequestSub ??=
client.onRoomKeyRequest.stream.listen((RoomKeyRequest request) async {
final room = request.room;
if (request.sender != room.client.userID) {
return; // ignore share requests by others
}
final sender = room.getUserByMXIDSync(request.sender);
if (await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
title: L10n.of(context).requestToReadOlderMessages,
message:
'${sender.id}\n\n${L10n.of(context).device}:\n${request.requestingDevice.deviceId}\n\n${L10n.of(context).publicKey}:\n${request.requestingDevice.ed25519Key.beautified}',
okLabel: L10n.of(context).verify,
cancelLabel: L10n.of(context).deny,
) ==
OkCancelResult.ok) {
await request.forwardKey();
}
});
onKeyVerificationRequestSub ??= client.onKeyVerificationRequest.stream
.listen((KeyVerification request) async {
var hidPopup = false;
request.onUpdate = () {
if (!hidPopup &&
{KeyVerificationState.done, KeyVerificationState.error}
.contains(request.state)) {
Navigator.of(context, rootNavigator: true).pop('dialog');
}
hidPopup = true;
};
if (await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
title: L10n.of(context).newVerificationRequest,
message: L10n.of(context).askVerificationRequest(request.userId),
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
) ==
OkCancelResult.ok) {
request.onUpdate = null;
hidPopup = true;
await request.acceptVerification();
await KeyVerificationDialog(request: request).show(context);
} else {
request.onUpdate = null;
hidPopup = true;
await request.rejectVerification();
}
});
_initWithStore();
if (kIsWeb) {
onFocusSub = html.window.onFocus.listen((_) => webHasFocus = true);
onBlurSub = html.window.onBlur.listen((_) => webHasFocus = false);
}
onLoginStateChanged ??= client.onLoginStateChanged.stream.listen((state) {
if (loginState != state) {
loginState = state;
widget.apl.currentState.pushNamedAndRemoveAllOthers('/');
}
});
// Cache and resend status message
onOwnPresence ??= client.onPresence.stream.listen((presence) {
if (client.isLogged() &&
client.userID == presence.senderId &&
presence.presence?.statusMsg != null) {
Logs().v('Update status message: "${presence.presence.statusMsg}"');
store.setItem(
SettingKeys.ownStatusMessage, presence.presence.statusMsg);
}
});
onUiaRequest ??= client.onUiaRequest.stream.listen(_onUiaRequest);
if (PlatformInfos.isWeb || PlatformInfos.isLinux) {
client.onSync.stream.first.then((s) {
html.Notification.requestPermission();
onNotification ??= client.onEvent.stream
.where((e) =>
e.type == EventUpdateType.timeline &&
[EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
.contains(e.content['type']) &&
e.content['sender'] != client.userID)
.listen(_showLocalNotification);
});
}
if (PlatformInfos.isMobile) {
_backgroundPush = BackgroundPush(client, context, widget.apl);
}
}
bool _firstStartup = true;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
Logs().v('AppLifecycleState = $state');
final foreground = state != AppLifecycleState.detached &&
state != AppLifecycleState.paused;
client.backgroundSync = foreground;
client.syncPresence = foreground ? null : PresenceType.unavailable;
client.requestHistoryOnLimitedTimeline = !foreground;
if (_firstStartup) {
_firstStartup = false;
_backgroundPush?.setupPush();
}
}
void initSettings() {
if (store != null) {
store.getItem(SettingKeys.jitsiInstance).then((final instance) =>
AppConfig.jitsiInstance = instance ?? AppConfig.jitsiInstance);
store.getItem(SettingKeys.wallpaper).then((final path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
wallpaper = file;
}
});
store.getItem(SettingKeys.fontSizeFactor).then((value) =>
AppConfig.fontSizeFactor =
double.tryParse(value ?? '') ?? AppConfig.fontSizeFactor);
store
.getItemBool(SettingKeys.renderHtml, AppConfig.renderHtml)
.then((value) => AppConfig.renderHtml = value);
store
.getItemBool(
SettingKeys.hideRedactedEvents, AppConfig.hideRedactedEvents)
.then((value) => AppConfig.hideRedactedEvents = value);
store
.getItemBool(
SettingKeys.hideUnknownEvents, AppConfig.hideUnknownEvents)
.then((value) => AppConfig.hideUnknownEvents = value);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
onRoomKeyRequestSub?.cancel();
onKeyVerificationRequestSub?.cancel();
onLoginStateChanged?.cancel();
onOwnPresence?.cancel();
onNotification?.cancel();
onFocusSub?.cancel();
onBlurSub?.cancel();
_backgroundPush?.onLogin?.cancel();
linuxNotifications.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Provider(
create: (_) => this,
child: widget.child,
);
}
}
class FixedThreepidCreds extends ThreepidCreds {
FixedThreepidCreds({
String sid,
String clientSecret,
String idServer,
String idAccessToken,
}) : super(
sid: sid,
clientSecret: clientSecret,
idServer: idServer,
idAccessToken: idAccessToken,
);
@override
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['sid'] = sid;
data['client_secret'] = clientSecret;
if (idServer != null) data['id_server'] = idServer;
if (idAccessToken != null) data['id_access_token'] = idAccessToken;
return data;
}
}

View file

@ -0,0 +1,201 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/encryption/utils/key_verification.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/audio_player.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/image_bubble.dart';
import 'package:fluffychat/utils/event_extension.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/views/key_verification_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:url_launcher/url_launcher.dart';
import '../utils/url_launcher.dart';
import '../config/app_config.dart';
import 'html_message.dart';
import 'matrix.dart';
import 'message_download_content.dart';
class MessageContent extends StatelessWidget {
final Event event;
final Color textColor;
const MessageContent(this.event, {this.textColor});
void _verifyOrRequestKey(BuildContext context) async {
if (event.content['can_request_session'] != true) {
AdaptivePageLayout.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(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) {
AdaptivePageLayout.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:
case MessageTypes.Sticker:
if (event.showThumbnail) {
return ImageBubble(event);
}
return MessageDownloadContent(event, textColor);
case MessageTypes.Audio:
return AudioPlayerWidget(
event,
color: textColor,
);
case MessageTypes.Video:
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: Icon(Icons.lock_outline),
label: Text(L10n.of(context).encrypted),
);
case MessageTypes.Location:
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: 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),
SizedBox(width: 4),
Text(
event.getLocalizedBody(MatrixLocals(L10n.of(context)),
hideReply: true),
style: TextStyle(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
decoration: TextDecoration.lineThrough,
decorationThickness: 0.5,
),
),
],
);
}
final bigEmotes = event.onlyEmotes &&
event.numberEmotes > 0 &&
event.numberEmotes <= 10;
return LinkText(
text: event.getLocalizedBody(MatrixLocals(L10n.of(context)),
hideReply: true),
textStyle: TextStyle(
color: textColor,
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration: event.redacted ? TextDecoration.lineThrough : null,
),
linkStyle: TextStyle(
color: textColor.withAlpha(150),
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration: TextDecoration.underline,
),
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
);
}
break;
default:
return Text(
L10n.of(context)
.userSentUnknownEvent(event.sender.calcDisplayname(), event.type),
style: TextStyle(
color: textColor,
decoration: event.redacted ? TextDecoration.lineThrough : null,
),
);
}
return Container(); // else flutter analyze complains
}
}

View file

@ -0,0 +1,53 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/utils/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 Container(
child: 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.openFile(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_outlined),
SizedBox(width: 8),
Text(
filename,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
],
),
),
if (event.sizeString != null)
Text(
event.sizeString,
style: TextStyle(
color: textColor,
),
),
],
),
);
}
}

View file

@ -0,0 +1,146 @@
import 'package:famedlysdk/famedlysdk.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 'matrix.dart';
class MessageReactions extends StatelessWidget {
final Event event;
final Timeline timeline;
const MessageReactions(this.event, this.timeline);
@override
Widget build(BuildContext context) {
final allReactionEvents =
event.aggregatedEvents(timeline, RelationshipTypes.reaction);
final reactionMap = <String, _ReactionEntry>{};
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,
);
}
reactionMap[key].count++;
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));
}
},
))
.toList(),
);
}
}
class _Reaction extends StatelessWidget {
final String reactionKey;
final int count;
final bool reacted;
final void Function() onTap;
const _Reaction({this.reactionKey, this.count, this.reacted, this.onTap});
@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).secondaryHeaderColor;
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,
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;
_ReactionEntry({this.key, this.count, this.reacted});
}

View file

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
class MouseOverBuilder extends StatefulWidget {
final Function(BuildContext, bool) builder;
const MouseOverBuilder({Key key, this.builder}) : super(key: key);
@override
_MouseOverBuilderState createState() => _MouseOverBuilderState();
}
class _MouseOverBuilderState extends State<MouseOverBuilder> {
bool _hover = false;
void _toggleHover(bool hover) {
if (_hover != hover) {
setState(() => _hover = hover);
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _toggleHover(true),
onExit: (_) => _toggleHover(false),
child: widget.builder != null ? widget.builder(context, _hover) : null,
);
}
}

View file

@ -0,0 +1,99 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'html_message.dart';
import '../config/app_config.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;
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';
}
final fontSize = DefaultTextStyle.of(context).style.fontSize;
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: DefaultTextStyle.of(context).style.fontSize,
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 3,
height: 36,
color: lightText ? Colors.white : Theme.of(context).primaryColor,
),
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,
),
),
replyBody,
],
),
),
],
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:fluffychat/utils/sentry_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class SentrySwitchListTile extends StatefulWidget {
final String label;
const SentrySwitchListTile({Key key, this.label}) : super(key: key);
@override
_SentrySwitchListTileState createState() => _SentrySwitchListTileState();
}
class _SentrySwitchListTileState extends State<SentrySwitchListTile> {
bool _enabled = false;
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: SentryController.getSentryStatus(),
builder: (context, snapshot) {
_enabled = snapshot.data ?? false;
return SwitchListTile(
title: Text(widget.label ?? L10n.of(context).sendBugReports),
value: _enabled,
onChanged: (b) =>
SentryController.toggleSentryAction(context, b).then(
(_) => setState(() => null),
),
);
});
}
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'matrix.dart';
class SettingsSwitchListTile extends StatefulWidget {
final bool defaultValue;
final String storeKey;
final String title;
final Function(bool) onChanged;
const SettingsSwitchListTile({
Key key,
this.defaultValue = false,
@required this.storeKey,
@required this.title,
this.onChanged,
}) : super(key: key);
@override
_SettingsSwitchListTileState createState() => _SettingsSwitchListTileState();
}
class _SettingsSwitchListTileState extends State<SettingsSwitchListTile> {
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: Matrix.of(context).store.getItemBool(widget.storeKey),
builder: (context, snapshot) => SwitchListTile(
value: snapshot.data ?? widget.defaultValue,
title: Text(widget.title),
onChanged: (bool newValue) async {
widget.onChanged?.call(newValue);
await Matrix.of(context)
.store
.setItem(widget.storeKey, newValue.toString());
setState(() => null);
},
),
);
}
}

View file

@ -0,0 +1,55 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import '../config/app_config.dart';
import 'matrix.dart';
class UnreadBadgeBackButton extends StatelessWidget {
final String roomId;
const UnreadBadgeBackButton({
Key key,
@required this.roomId,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
Center(child: BackButton()),
StreamBuilder(
stream: Matrix.of(context).client.onSync.stream,
builder: (context, _) {
final unreadCount = Matrix.of(context)
.client
.rooms
.where((r) =>
r.id != roomId &&
(r.isUnread || r.membership == Membership.invite))
.length;
return unreadCount > 0
? Align(
alignment: Alignment.bottomRight,
child: Container(
padding: EdgeInsets.all(4),
margin: EdgeInsets.only(bottom: 4, right: 8),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
),
child: Text(
'$unreadCount',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
)
: Container();
}),
],
);
}
}