refactor: Move widgets to lib
This commit is contained in:
parent
3a200a98d8
commit
c8827ae1b2
86 changed files with 136 additions and 136 deletions
34
lib/widgets/adaptive_flat_button.dart
Normal file
34
lib/widgets/adaptive_flat_button.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
lib/widgets/audio_player.dart
Normal file
210
lib/widgets/audio_player.dart
Normal 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
88
lib/widgets/avatar.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
lib/widgets/chat_settings_popup_menu.dart
Normal file
110
lib/widgets/chat_settings_popup_menu.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/widgets/connection_status_header.dart
Normal file
44
lib/widgets/connection_status_header.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
lib/widgets/contacts_list.dart
Normal file
111
lib/widgets/contacts_list.dart
Normal 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)}'),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
77
lib/widgets/content_banner.dart
Normal file
77
lib/widgets/content_banner.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/widgets/default_app_bar_search_field.dart
Normal file
118
lib/widgets/default_app_bar_search_field.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/widgets/encryption_button.dart
Normal file
115
lib/widgets/encryption_button.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
10
lib/widgets/fluffy_banner.dart
Normal file
10
lib/widgets/fluffy_banner.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
133
lib/widgets/html_message.dart
Normal file
133
lib/widgets/html_message.dart
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
273
lib/widgets/image_bubble.dart
Normal file
273
lib/widgets/image_bubble.dart
Normal 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
305
lib/widgets/input_bar.dart
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/widgets/layouts/loading_view.dart
Normal file
14
lib/widgets/layouts/loading_view.dart
Normal 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()));
|
||||
}
|
||||
}
|
||||
38
lib/widgets/layouts/max_width_body.dart
Normal file
38
lib/widgets/layouts/max_width_body.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/widgets/layouts/one_page_card.dart
Normal file
45
lib/widgets/layouts/one_page_card.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
287
lib/widgets/list_items/chat_list_item.dart
Normal file
287
lib/widgets/list_items/chat_list_item.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
284
lib/widgets/list_items/message.dart
Normal file
284
lib/widgets/list_items/message.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/widgets/list_items/participant_list_item.dart
Normal file
66
lib/widgets/list_items/participant_list_item.dart
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/widgets/list_items/permission_list_tile.dart
Normal file
103
lib/widgets/list_items/permission_list_tile.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
64
lib/widgets/list_items/public_room_list_item.dart
Normal file
64
lib/widgets/list_items/public_room_list_item.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/widgets/list_items/state_message.dart
Normal file
67
lib/widgets/list_items/state_message.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/widgets/list_items/user_device_list_item.dart
Normal file
134
lib/widgets/list_items/user_device_list_item.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
lib/widgets/lock_screen.dart
Normal file
86
lib/widgets/lock_screen.dart
Normal 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
65
lib/widgets/log_view.dart
Normal 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
480
lib/widgets/matrix.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
201
lib/widgets/message_content.dart
Normal file
201
lib/widgets/message_content.dart
Normal 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
|
||||
}
|
||||
}
|
||||
53
lib/widgets/message_download_content.dart
Normal file
53
lib/widgets/message_download_content.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/widgets/message_reactions.dart
Normal file
146
lib/widgets/message_reactions.dart
Normal 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});
|
||||
}
|
||||
28
lib/widgets/mouse_over_builder.dart
Normal file
28
lib/widgets/mouse_over_builder.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/widgets/reply_content.dart
Normal file
99
lib/widgets/reply_content.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/widgets/sentry_switch_list_tile.dart
Normal file
33
lib/widgets/sentry_switch_list_tile.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
41
lib/widgets/settings_switch_list_tile.dart
Normal file
41
lib/widgets/settings_switch_list_tile.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
lib/widgets/unread_badge_back_button.dart
Normal file
55
lib/widgets/unread_badge_back_button.dart
Normal 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();
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue