refactor: Rename views to pages

This commit is contained in:
Christian Pauly 2021-05-22 08:57:49 +02:00
commit 74606b1600
63 changed files with 77 additions and 77 deletions

51
lib/pages/archive.dart Normal file
View file

@ -0,0 +1,51 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/views/archive_view.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class Archive extends StatefulWidget {
@override
ArchiveController createState() => ArchiveController();
}
class ArchiveController extends State<Archive> {
List<Room> archive;
Future<List<Room>> getArchive(BuildContext context) async {
if (archive != null) return archive;
return await Matrix.of(context).client.archive;
}
void forgetAction(int i) => setState(() => archive.removeAt(i));
void forgetAllAction() async {
if (await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
message: L10n.of(context).clearArchive,
useRootNavigator: false,
) !=
OkCancelResult.ok) {
return;
}
await showFutureLoadingDialog(
context: context,
future: () async {
while (archive.isNotEmpty) {
Logs().v('Forget room ${archive.last.displayname}');
await archive.last.forget();
archive.removeLast();
}
},
);
setState(() => null);
}
@override
Widget build(BuildContext context) => ArchiveView(this);
}

View file

@ -0,0 +1,280 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/encryption.dart';
import 'package:famedlysdk/encryption/utils/bootstrap.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/adaptive_flat_button.dart';
import 'package:flutter/services.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'key_verification_dialog.dart';
class BootstrapDialog extends StatefulWidget {
final bool wipe;
final Client client;
const BootstrapDialog({
Key key,
this.wipe = false,
@required this.client,
}) : super(key: key);
Future<bool> show(BuildContext context) => PlatformInfos.isCupertinoStyle
? showCupertinoDialog(
context: context,
builder: (context) => this,
useRootNavigator: false,
barrierDismissible: true,
)
: showDialog(
context: context,
builder: (context) => this,
useRootNavigator: false,
barrierDismissible: true,
);
@override
_BootstrapDialogState createState() => _BootstrapDialogState();
}
class _BootstrapDialogState extends State<BootstrapDialog> {
final TextEditingController _recoveryKeyTextEditingController =
TextEditingController();
Bootstrap bootstrap;
String _recoveryKeyInputError;
bool _recoveryKeyInputLoading = false;
String titleText;
bool _recoveryKeyStored = false;
bool _wipe;
void _createBootstrap(bool wipe) {
setState(() {
_wipe = wipe;
titleText = null;
_recoveryKeyStored = false;
bootstrap = widget.client.encryption
.bootstrap(onUpdate: () => setState(() => null));
});
}
@override
Widget build(BuildContext context) {
_wipe ??= widget.wipe;
final buttons = <AdaptiveFlatButton>[];
Widget body = LinearProgressIndicator();
titleText = L10n.of(context).loadingPleaseWait;
if (bootstrap == null) {
titleText = L10n.of(context).chatBackup;
body = Text(L10n.of(context).chatBackupDescription);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).next,
onPressed: () => _createBootstrap(false),
));
} else if (bootstrap.newSsssKey?.recoveryKey != null &&
_recoveryKeyStored == false) {
final key = bootstrap.newSsssKey.recoveryKey;
titleText = L10n.of(context).securityKey;
body = Container(
alignment: Alignment.center,
width: 200,
height: 128,
child: Text(
key,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
wordSpacing: 38,
fontFamily: 'monospace',
),
),
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).copyToClipboard,
onPressed: () => Clipboard.setData(ClipboardData(text: key)),
));
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).next,
onPressed: () => setState(() => _recoveryKeyStored = true),
));
} else {
switch (bootstrap.state) {
case BootstrapState.loading:
break;
case BootstrapState.askWipeSsss:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bootstrap.wipeSsss(_wipe),
);
break;
case BootstrapState.askUseExistingSsss:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bootstrap.useExistingSsss(!_wipe),
);
break;
case BootstrapState.askUnlockSsss:
throw Exception('This state is not supposed to be implemented');
case BootstrapState.askNewSsss:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bootstrap.newSsss(),
);
break;
case BootstrapState.openExistingSsss:
_recoveryKeyStored = true;
titleText =
_recoveryKeyInputError ?? L10n.of(context).pleaseEnterSecurityKey;
body = PlatformInfos.isCupertinoStyle
? CupertinoTextField(
minLines: 1,
maxLines: 1,
autofocus: true,
autocorrect: false,
autofillHints: _recoveryKeyInputLoading
? null
: [AutofillHints.password],
controller: _recoveryKeyTextEditingController,
)
: TextField(
minLines: 1,
maxLines: 1,
autofocus: true,
autocorrect: false,
autofillHints: _recoveryKeyInputLoading
? null
: [AutofillHints.password],
controller: _recoveryKeyTextEditingController,
decoration: InputDecoration(
border: UnderlineInputBorder(),
filled: false,
hintText: L10n.of(context).securityKey,
),
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).unlockChatBackup,
onPressed: () async {
setState(() {
_recoveryKeyInputError = null;
_recoveryKeyInputLoading = true;
});
try {
await bootstrap.newSsssKey.unlock(
keyOrPassphrase: _recoveryKeyTextEditingController.text,
);
await bootstrap.openExistingSsss();
} catch (e, s) {
Logs().w('Unable to unlock SSSS', e, s);
setState(() => _recoveryKeyInputError =
L10n.of(context).oopsSomethingWentWrong);
} finally {
setState(() => _recoveryKeyInputLoading = false);
}
}));
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).transferFromAnotherDevice,
onPressed: () async {
final req = await showFutureLoadingDialog(
context: context,
future: () => widget.client.userDeviceKeys[widget.client.userID]
.startVerification(),
);
if (req.error != null) return;
await KeyVerificationDialog(request: req.result).show(context);
Navigator.of(context, rootNavigator: false).pop();
},
));
buttons.add(AdaptiveFlatButton(
textColor: Colors.red,
label: L10n.of(context).securityKeyLost,
onPressed: () async {
if (OkCancelResult.ok ==
await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
title: L10n.of(context).securityKeyLost,
message: L10n.of(context).wipeChatBackup,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
isDestructiveAction: true,
)) {
_createBootstrap(true);
}
},
));
break;
case BootstrapState.askWipeCrossSigning:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bootstrap.wipeCrossSigning(_wipe),
);
break;
case BootstrapState.askSetupCrossSigning:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bootstrap.askSetupCrossSigning(
setupMasterKey: true,
setupSelfSigningKey: true,
setupUserSigningKey: true,
),
);
break;
case BootstrapState.askWipeOnlineKeyBackup:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bootstrap.wipeOnlineKeyBackup(_wipe),
);
break;
case BootstrapState.askSetupOnlineKeyBackup:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bootstrap.askSetupOnlineKeyBackup(true),
);
break;
case BootstrapState.askBadSsss:
case BootstrapState.error:
titleText = L10n.of(context).oopsSomethingWentWrong;
body = ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.error_outline, color: Colors.red),
title: Text(L10n.of(context).oopsSomethingWentWrong),
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).close,
onPressed: () =>
Navigator.of(context, rootNavigator: false).pop<bool>(false),
));
break;
case BootstrapState.done:
titleText = L10n.of(context).everythingReady;
body = ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.check_circle, color: Colors.green),
title: Text(L10n.of(context).keysCached),
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).close,
onPressed: () =>
Navigator.of(context, rootNavigator: false).pop<bool>(false),
));
break;
}
}
final title = Text(titleText);
if (PlatformInfos.isCupertinoStyle) {
return CupertinoAlertDialog(
title: title,
content: body,
actions: buttons,
);
}
return AlertDialog(
title: title,
content: body,
actions: buttons,
);
}
}

657
lib/pages/chat.dart Normal file
View file

@ -0,0 +1,657 @@
import 'dart:async';
import 'dart:io';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/views/chat_ui.dart';
import 'package:fluffychat/pages/recording_dialog.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:url_launcher/url_launcher.dart';
import 'send_file_dialog.dart';
import '../utils/filtered_timeline_extension.dart';
import '../utils/matrix_file_extension.dart';
class Chat extends StatefulWidget {
final String id;
final String scrollToEventId;
Chat(this.id, {Key key, this.scrollToEventId})
: super(key: key ?? Key('chatroom-$id'));
@override
ChatController createState() => ChatController();
}
class ChatController extends State<Chat> {
Room room;
Timeline timeline;
MatrixState matrix;
final AutoScrollController scrollController = AutoScrollController();
FocusNode inputFocus = FocusNode();
Timer typingCoolDown;
Timer typingTimeout;
bool currentlyTyping = false;
List<Event> selectedEvents = [];
List<Event> filteredEvents;
final Set<String> unfolded = {};
Event replyEvent;
Event editEvent;
bool showScrollDownButton = false;
bool get selectMode => selectedEvents.isNotEmpty;
final int _loadHistoryCount = 100;
String inputText = '';
String pendingText = '';
bool get canLoadMore => timeline.events.last.type != EventTypes.RoomCreate;
bool showEmojiPicker = false;
void startCallAction() async {
final url =
'${AppConfig.jitsiInstance}${Uri.encodeComponent(Matrix.of(context).client.generateUniqueTransactionId())}';
final success = await showFutureLoadingDialog(
context: context,
future: () => room.sendEvent({
'msgtype': Matrix.callNamespace,
'body': url,
}));
if (success.error != null) return;
await launch(url);
}
void requestHistory() async {
if (canLoadMore) {
try {
await timeline.requestHistory(historyCount: _loadHistoryCount);
} catch (err) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(err.toLocalizedString(context))));
}
}
}
void _updateScrollController() {
if (!mounted) {
return;
}
if (scrollController.position.pixels ==
scrollController.position.maxScrollExtent &&
timeline.events.isNotEmpty &&
timeline.events[timeline.events.length - 1].type !=
EventTypes.RoomCreate) {
requestHistory();
}
if (scrollController.position.pixels > 0 && showScrollDownButton == false) {
setState(() => showScrollDownButton = true);
} else if (scrollController.position.pixels == 0 &&
showScrollDownButton == true) {
setState(() => showScrollDownButton = false);
}
}
@override
void initState() {
scrollController.addListener(_updateScrollController);
super.initState();
}
void updateView() {
if (!mounted) return;
setState(
() {
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
},
);
}
void unfold(String eventId) {
var i = filteredEvents.indexWhere((e) => e.eventId == eventId);
setState(() {
while (i < filteredEvents.length - 1 && filteredEvents[i].isState) {
unfolded.add(filteredEvents[i].eventId);
i++;
}
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
});
}
Future<bool> getTimeline() async {
if (timeline == null) {
timeline = await room.getTimeline(onUpdate: updateView);
if (timeline.events.isNotEmpty) {
// ignore: unawaited_futures
room.setUnread(false).catchError((err) {
if (err is MatrixException && err.errcode == 'M_FORBIDDEN') {
// ignore if the user is not in the room (still joining)
return;
}
throw err;
});
}
// when the scroll controller is attached we want to scroll to an event id, if specified
// and update the scroll controller...which will trigger a request history, if the
// "load more" button is visible on the screen
SchedulerBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
if (widget.scrollToEventId != null) {
scrollToEventId(widget.scrollToEventId);
}
_updateScrollController();
}
});
}
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
if (room.notificationCount != null &&
room.notificationCount > 0 &&
timeline != null &&
timeline.events.isNotEmpty &&
Matrix.of(context).webHasFocus) {
// ignore: unawaited_futures
room.setReadMarker(
timeline.events.first.eventId,
readReceiptLocationEventId: timeline.events.first.eventId,
);
if (PlatformInfos.isIOS) {
// Workaround for iOS not clearing notifications with fcm_shared_isolate
if (!room.client.rooms.any((r) =>
r.membership == Membership.invite ||
(r.notificationCount != null && r.notificationCount > 0))) {
// ignore: unawaited_futures
FlutterLocalNotificationsPlugin().cancelAll();
}
}
}
return true;
}
@override
void dispose() {
timeline?.cancelSubscriptions();
timeline = null;
matrix.client.activeRoomId = '';
super.dispose();
}
TextEditingController sendController = TextEditingController();
void send() {
if (sendController.text.trim().isEmpty) return;
room.sendTextEvent(sendController.text,
inReplyTo: replyEvent, editEventId: editEvent?.eventId);
sendController.value = TextEditingValue(
text: pendingText,
selection: TextSelection.collapsed(offset: 0),
);
setState(() {
inputText = pendingText;
replyEvent = null;
editEvent = null;
pendingText = '';
});
}
void sendFileAction() async {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.any);
if (result == null) return;
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
file: MatrixFile(
bytes: result.toUint8List(),
name: result.fileName,
).detectFileType,
room: room,
),
);
}
void sendImageAction() async {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
if (result == null) return;
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
file: MatrixImageFile(
bytes: result.toUint8List(),
name: result.fileName,
),
room: room,
),
);
}
void openCameraAction() async {
final file = await ImagePicker().getImage(source: ImageSource.camera);
if (file == null) return;
final bytes = await file.readAsBytes();
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
file: MatrixImageFile(
bytes: bytes,
name: file.path,
),
room: room,
),
);
}
void voiceMessageAction() async {
if (await Permission.microphone.isGranted != true) {
final status = await Permission.microphone.request();
if (status != PermissionStatus.granted) return;
}
final result = await showDialog<String>(
context: context,
builder: (c) => RecordingDialog(),
useRootNavigator: false,
);
if (result == null) return;
final audioFile = File(result);
// as we already explicitly say send in the recording dialog,
// we do not need the send file dialog anymore. We can just send this straight away.
await showFutureLoadingDialog(
context: context,
future: () => room.sendFileEvent(
MatrixAudioFile(
bytes: audioFile.readAsBytesSync(), name: audioFile.path),
),
);
}
String _getSelectedEventString() {
var copyString = '';
if (selectedEvents.length == 1) {
return selectedEvents.first
.getDisplayEvent(timeline)
.getLocalizedBody(MatrixLocals(L10n.of(context)));
}
for (final event in selectedEvents) {
if (copyString.isNotEmpty) copyString += '\n\n';
copyString += event.getDisplayEvent(timeline).getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: true);
}
return copyString;
}
void copyEventsAction() {
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
setState(() => selectedEvents.clear());
}
void reportEventAction() async {
final event = selectedEvents.single;
final score = await showConfirmationDialog<int>(
context: context,
title: L10n.of(context).howOffensiveIsThisContent,
useRootNavigator: false,
actions: [
AlertDialogAction(
key: -100,
label: L10n.of(context).extremeOffensive,
),
AlertDialogAction(
key: -50,
label: L10n.of(context).offensive,
),
AlertDialogAction(
key: 0,
label: L10n.of(context).inoffensive,
),
]);
if (score == null) return;
final reason = await showTextInputDialog(
context: context,
title: L10n.of(context).whyDoYouWantToReportThis,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [DialogTextField(hintText: L10n.of(context).reason)]);
if (reason == null || reason.single.isEmpty) return;
final result = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.reportContent(
event.roomId,
event.eventId,
reason.single,
score,
),
);
if (result.error != null) return;
setState(() => selectedEvents.clear());
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).contentHasBeenReported)));
}
void redactEventsAction() async {
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).messageWillBeRemovedWarning,
okLabel: L10n.of(context).remove,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) ==
OkCancelResult.ok;
if (!confirmed) return;
for (final event in selectedEvents) {
await showFutureLoadingDialog(
context: context,
future: () =>
event.status > 0 ? event.redactEvent() : event.remove());
}
setState(() => selectedEvents.clear());
}
bool get canRedactSelectedEvents {
for (final event in selectedEvents) {
if (event.canRedact == false) return false;
}
return true;
}
void forwardEventsAction() async {
if (selectedEvents.length == 1) {
Matrix.of(context).shareContent = selectedEvents.first.content;
} else {
Matrix.of(context).shareContent = {
'msgtype': 'm.text',
'body': _getSelectedEventString(),
};
}
setState(() => selectedEvents.clear());
AdaptivePageLayout.of(context).popUntilIsFirst();
}
void sendAgainAction() {
final event = selectedEvents.first;
if (event.status == -1) {
event.sendAgain();
}
final allEditEvents = event
.aggregatedEvents(timeline, RelationshipTypes.edit)
.where((e) => e.status == -1);
for (final e in allEditEvents) {
e.sendAgain();
}
setState(() => selectedEvents.clear());
}
void replyAction({Event replyTo}) {
setState(() {
replyEvent = replyTo ?? selectedEvents.first;
selectedEvents.clear();
});
inputFocus.requestFocus();
}
void scrollToEventId(String eventId) async {
var eventIndex = filteredEvents.indexWhere((e) => e.eventId == eventId);
if (eventIndex == -1) {
// event id not found...maybe we can fetch it?
// the try...finally is here to start and close the loading dialog reliably
final task = Future.microtask(() async {
// okay, we first have to fetch if the event is in the room
try {
final event = await timeline.getEventById(eventId);
if (event == null) {
// event is null...meaning something is off
return;
}
} catch (err) {
if (err is MatrixException && err.errcode == 'M_NOT_FOUND') {
// event wasn't found, as the server gave a 404 or something
return;
}
rethrow;
}
// okay, we know that the event *is* in the room
while (eventIndex == -1) {
if (!canLoadMore) {
// we can't load any more events but still haven't found ours yet...better stop here
return;
}
try {
await timeline.requestHistory(historyCount: _loadHistoryCount);
} catch (err) {
if (err is TimeoutException) {
// loading the history timed out...so let's do nothing
return;
}
rethrow;
}
eventIndex = filteredEvents.indexWhere((e) => e.eventId == eventId);
}
});
if (context != null) {
await showFutureLoadingDialog(context: context, future: () => task);
} else {
await task;
}
}
if (!mounted) {
return;
}
await scrollController.scrollToIndex(eventIndex,
preferPosition: AutoScrollPosition.middle);
_updateScrollController();
}
void scrollDown() => scrollController.jumpTo(0);
void onEmojiSelected(category, emoji) {
setState(() => showEmojiPicker = false);
if (emoji == null) return;
// make sure we don't send the same emoji twice
if (_allReactionEvents
.any((e) => e.content['m.relates_to']['key'] == emoji.emoji)) return;
return sendEmojiAction(emoji.emoji);
}
Iterable<Event> _allReactionEvents;
void cancelEmojiPicker() => setState(() => showEmojiPicker = false);
void pickEmojiAction(Iterable<Event> allReactionEvents) async {
_allReactionEvents = allReactionEvents;
setState(() => showEmojiPicker = true);
}
void sendEmojiAction(String emoji) async {
await showFutureLoadingDialog(
context: context,
future: () => room.sendReaction(
selectedEvents.single.eventId,
emoji,
),
);
setState(() => selectedEvents.clear());
}
void clearSelectedEvents() => setState(() {
selectedEvents.clear();
showEmojiPicker = false;
});
void editSelectedEventAction() {
setState(() {
pendingText = sendController.text;
editEvent = selectedEvents.first;
inputText = sendController.text = editEvent
.getDisplayEvent(timeline)
.getLocalizedBody(MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false, hideReply: true);
selectedEvents.clear();
});
inputFocus.requestFocus();
}
void onEventActionPopupMenuSelected(selected) {
switch (selected) {
case 'copy':
copyEventsAction();
break;
case 'redact':
redactEventsAction();
break;
case 'report':
reportEventAction();
break;
}
}
void goToNewRoomAction() async {
if (OkCancelResult.ok !=
await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).goToTheNewRoom,
message: room
.getState(EventTypes.RoomTombstone)
.parsedTombstoneContent
.body,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
)) {
return;
}
final result = await showFutureLoadingDialog(
context: context,
future: () => room.client.joinRoom(room
.getState(EventTypes.RoomTombstone)
.parsedTombstoneContent
.replacementRoom),
);
await showFutureLoadingDialog(
context: context,
future: room.leave,
);
if (result.error == null) {
await AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/rooms/${result.result}');
}
}
void onSelectMessage(Event event) {
if (!event.redacted) {
if (selectedEvents.contains(event)) {
setState(
() => selectedEvents.remove(event),
);
} else {
setState(
() => selectedEvents.add(event),
);
}
selectedEvents.sort(
(a, b) => a.originServerTs.compareTo(b.originServerTs),
);
}
}
int findChildIndexCallback(Key key, Map<String, int> thisEventsKeyMap) {
// this method is called very often. As such, it has to be optimized for speed.
if (!(key is ValueKey)) {
return null;
}
final eventId = (key as ValueKey).value;
if (!(eventId is String)) {
return null;
}
// first fetch the last index the event was at
final index = thisEventsKeyMap[eventId];
if (index == null) {
return null;
}
// we need to +1 as 0 is the typing thing at the bottom
return index + 1;
}
void onInputBarSubmitted(String text) {
send();
FocusScope.of(context).requestFocus(inputFocus);
}
void onAddPopupMenuButtonSelected(String choice) {
if (choice == 'file') {
sendFileAction();
} else if (choice == 'image') {
sendImageAction();
}
if (choice == 'camera') {
openCameraAction();
}
if (choice == 'voice') {
voiceMessageAction();
}
}
void onInputBarChanged(String text) {
typingCoolDown?.cancel();
typingCoolDown = Timer(Duration(seconds: 2), () {
typingCoolDown = null;
currentlyTyping = false;
room.setTyping(false);
});
typingTimeout ??= Timer(Duration(seconds: 30), () {
typingTimeout = null;
currentlyTyping = false;
});
if (!currentlyTyping) {
currentlyTyping = true;
room.setTyping(true, timeout: Duration(seconds: 30).inMilliseconds);
}
setState(() => inputText = text);
}
void cancelReplyEventAction() => setState(() {
if (editEvent != null) {
inputText = sendController.text = pendingText;
pendingText = '';
}
replyEvent = null;
editEvent = null;
});
@override
Widget build(BuildContext context) => ChatUI(this);
}

303
lib/pages/chat_details.dart Normal file
View file

@ -0,0 +1,303 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/pages/views/chat_details_ui.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/services.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:image_picker/image_picker.dart';
enum AliasActions { copy, delete, setCanonical }
class ChatDetails extends StatefulWidget {
final String roomId;
const ChatDetails(this.roomId);
@override
ChatDetailsController createState() => ChatDetailsController();
}
class ChatDetailsController extends State<ChatDetails> {
List<User> members;
@override
void initState() {
super.initState();
members ??=
Matrix.of(context).client.getRoomById(widget.roomId).getParticipants();
}
void setDisplaynameAction() async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).changeTheNameOfTheGroup,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
initialText: room.getLocalizedDisplayname(
MatrixLocals(
L10n.of(context),
),
),
)
],
);
if (input == null) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => room.setName(input.single),
);
if (success.error == null) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).displaynameHasBeenChanged)));
}
}
void editAliases() async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
// The current endpoint doesnt seem to be implemented in Synapse. This may
// change in the future and then we just need to switch to this api call:
//
// final aliases = await showFutureLoadingDialog(
// context: context,
// future: () => room.client.requestRoomAliases(room.id),
// );
//
// While this is not working we use the unstable api:
final aliases = await showFutureLoadingDialog(
context: context,
future: () => room.client
.request(
RequestType.GET,
'/client/unstable/org.matrix.msc2432/rooms/${Uri.encodeComponent(room.id)}/aliases',
)
.then((response) => List<String>.from(response['aliases'])),
);
// Switch to the stable api once it is implemented.
if (aliases.error != null) return;
final adminMode = room.canSendEvent('m.room.canonical_alias');
if (aliases.result.isEmpty && (room.canonicalAlias?.isNotEmpty ?? false)) {
aliases.result.add(room.canonicalAlias);
}
if (aliases.result.isEmpty && adminMode) {
return setAliasAction();
}
final select = await showConfirmationDialog(
context: context,
title: L10n.of(context).editRoomAliases,
actions: [
if (adminMode)
AlertDialogAction(label: L10n.of(context).create, key: 'new'),
...aliases.result
.map((alias) => AlertDialogAction(key: alias, label: alias))
.toList(),
],
);
if (select == null) return;
if (select == 'new') {
return setAliasAction();
}
final option = await showConfirmationDialog<AliasActions>(
context: context,
title: select,
actions: [
AlertDialogAction(
label: L10n.of(context).copyToClipboard,
key: AliasActions.copy,
isDefaultAction: true,
),
if (adminMode) ...{
AlertDialogAction(
label: L10n.of(context).setAsCanonicalAlias,
key: AliasActions.setCanonical,
isDestructiveAction: true,
),
AlertDialogAction(
label: L10n.of(context).delete,
key: AliasActions.delete,
isDestructiveAction: true,
),
},
],
);
switch (option) {
case AliasActions.copy:
await Clipboard.setData(ClipboardData(text: select));
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).copiedToClipboard)),
);
break;
case AliasActions.delete:
await showFutureLoadingDialog(
context: context,
future: () => room.client.deleteRoomAlias(select),
);
break;
case AliasActions.setCanonical:
await showFutureLoadingDialog(
context: context,
future: () => room.client
.setRoomStateWithKey(room.id, EventTypes.RoomCanonicalAlias, {
'alias': select,
}),
);
break;
}
}
void setAliasAction() async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final domain = room.client.userID.domain;
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).setInvitationLink,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
prefixText: '#',
suffixText: domain,
hintText: L10n.of(context).alias,
initialText: room.canonicalAlias?.localpart,
)
],
);
if (input == null) return;
await showFutureLoadingDialog(
context: context,
future: () =>
room.client.setRoomAlias('#' + input.single + ':' + domain, room.id),
);
}
void setTopicAction() async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).setGroupDescription,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: L10n.of(context).setGroupDescription,
initialText: room.topic,
minLines: 1,
maxLines: 4,
)
],
);
if (input == null) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => room.setDescription(input.single),
);
if (success.error == null) {
AdaptivePageLayout.of(context).showSnackBar(SnackBar(
content: Text(L10n.of(context).groupDescriptionHasBeenChanged)));
}
}
void setGuestAccessAction(GuestAccess guestAccess) => showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context)
.client
.getRoomById(widget.roomId)
.setGuestAccess(guestAccess),
);
void setHistoryVisibilityAction(HistoryVisibility historyVisibility) =>
showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context)
.client
.getRoomById(widget.roomId)
.setHistoryVisibility(historyVisibility),
);
void setJoinRulesAction(JoinRules joinRule) => showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context)
.client
.getRoomById(widget.roomId)
.setJoinRules(joinRule),
);
void goToEmoteSettings() async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
// okay, we need to test if there are any emote state events other than the default one
// if so, we need to be directed to a selection screen for which pack we want to look at
// otherwise, we just open the normal one.
if ((room.states['im.ponies.room_emotes'] ?? <String, Event>{})
.keys
.any((String s) => s.isNotEmpty)) {
await AdaptivePageLayout.of(context)
.pushNamed('/rooms/${room.id}/emotes');
} else {
await AdaptivePageLayout.of(context)
.pushNamed('/settings/emotes', arguments: {'room': room});
}
}
void setAvatarAction() async {
MatrixFile file;
if (PlatformInfos.isMobile) {
final result = await ImagePicker().getImage(
source: ImageSource.gallery,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (result == null) return;
file = MatrixFile(
bytes: await result.readAsBytes(),
name: result.path,
);
} else {
final result = await FilePickerCross.importFromStorage(
type: FileTypeCross.image,
);
if (result == null) return;
file = MatrixFile(
bytes: result.toUint8List(),
name: result.fileName,
);
}
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final success = await showFutureLoadingDialog(
context: context,
future: () => room.setAvatar(file),
);
if (success.error == null) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).avatarHasBeenChanged)));
}
}
void requestMoreMembersAction() async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final participants = await showFutureLoadingDialog(
context: context, future: () => room.requestParticipants());
if (participants.error == null) {
setState(() => members = participants.result);
}
}
@override
Widget build(BuildContext context) => ChatDetailsUI(this);
}

View file

@ -0,0 +1,65 @@
import 'package:famedlysdk/encryption.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/views/chat_encryption_settings_ui.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'key_verification_dialog.dart';
class ChatEncryptionSettings extends StatefulWidget {
final String id;
const ChatEncryptionSettings(this.id, {Key key}) : super(key: key);
@override
ChatEncryptionSettingsController createState() =>
ChatEncryptionSettingsController();
}
class ChatEncryptionSettingsController extends State<ChatEncryptionSettings> {
Future<void> onSelected(
BuildContext context, String action, DeviceKeys key) async {
final room = Matrix.of(context).client.getRoomById(widget.id);
final unblock = () async {
if (key.blocked) {
await key.setBlocked(false);
}
};
switch (action) {
case 'verify':
await unblock();
final req = key.startVerification();
req.onUpdate = () {
if (req.state == KeyVerificationState.done) {
setState(() => null);
}
};
await KeyVerificationDialog(request: req).show(context);
break;
case 'verify_user':
await unblock();
final req =
await room.client.userDeviceKeys[key.userId].startVerification();
req.onUpdate = () {
if (req.state == KeyVerificationState.done) {
setState(() => null);
}
};
await KeyVerificationDialog(request: req).show(context);
break;
case 'block':
if (key.directVerified) {
await key.setVerified(false);
}
await key.setBlocked(true);
setState(() => null);
break;
case 'unblock':
await unblock();
setState(() => null);
break;
}
}
@override
Widget build(BuildContext context) => ChatEncryptionSettingsUI(this);
}

258
lib/pages/chat_list.dart Normal file
View file

@ -0,0 +1,258 @@
import 'dart:async';
import 'dart:io';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/pages/views/chat_list_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:uni_links/uni_links.dart';
import '../main.dart';
import '../widgets/matrix.dart';
import '../utils/matrix_file_extension.dart';
import '../utils/url_launcher.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
enum SelectMode { normal, share, select }
enum PopupMenuAction { settings, invite, newGroup, setStatus, archive }
class ChatList extends StatefulWidget {
final String activeChat;
const ChatList({this.activeChat, Key key}) : super(key: key);
@override
ChatListController createState() => ChatListController();
}
class ChatListController extends State<ChatList> {
StreamSubscription _intentDataStreamSubscription;
StreamSubscription _intentFileStreamSubscription;
StreamSubscription _intentUriStreamSubscription;
final selectedRoomIds = <String>{};
void _processIncomingSharedFiles(List<SharedMediaFile> files) {
if (files?.isEmpty ?? true) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
final file = File(files.first.path);
Matrix.of(context).shareContent = {
'msgtype': 'chat.fluffy.shared_file',
'file': MatrixFile(
bytes: file.readAsBytesSync(),
name: file.path,
).detectFileType,
};
}
void _processIncomingSharedText(String text) {
if (text == null) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
if (text.toLowerCase().startsWith(AppConfig.inviteLinkPrefix) ||
(text.toLowerCase().startsWith(AppConfig.schemePrefix) &&
!RegExp(r'\s').hasMatch(text))) {
return;
}
Matrix.of(context).shareContent = {
'msgtype': 'm.text',
'body': text,
};
}
void _processIncomingUris(String text) async {
if (text == null || !text.startsWith(AppConfig.appOpenUrlScheme)) return;
if (text.toLowerCase().startsWith(AppConfig.inviteLinkPrefix) ||
(text.toLowerCase().startsWith(AppConfig.schemePrefix) &&
!RegExp(r'\s').hasMatch(text))) {
AdaptivePageLayout.of(context).popUntilIsFirst();
UrlLauncher(context, text).openMatrixToUrl();
return;
}
}
void _initReceiveSharingIntent() {
if (!PlatformInfos.isMobile) return;
// For sharing images coming from outside the app while the app is in the memory
_intentFileStreamSubscription = ReceiveSharingIntent.getMediaStream()
.listen(_processIncomingSharedFiles, onError: print);
// For sharing images coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then(_processIncomingSharedFiles);
// For sharing or opening urls/text coming from outside the app while the app is in the memory
_intentDataStreamSubscription = ReceiveSharingIntent.getTextStream()
.listen(_processIncomingSharedText, onError: print);
// For sharing or opening urls/text coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialText().then(_processIncomingSharedText);
// For receiving shared Uris
_intentDataStreamSubscription = linkStream.listen(_processIncomingUris);
if (FluffyChatApp.gotInitialLink == false) {
FluffyChatApp.gotInitialLink = true;
getInitialLink().then(_processIncomingUris);
}
}
@override
void initState() {
_initReceiveSharingIntent();
super.initState();
}
@override
void dispose() {
_intentDataStreamSubscription?.cancel();
_intentFileStreamSubscription?.cancel();
_intentUriStreamSubscription?.cancel();
super.dispose();
}
void toggleSelection(String roomId) {
setState(() => selectedRoomIds.contains(roomId)
? selectedRoomIds.remove(roomId)
: selectedRoomIds.add(roomId));
}
Future<void> toggleUnread() {
final room = Matrix.of(context).client.getRoomById(selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setUnread(!room.isUnread),
);
}
Future<void> toggleFavouriteRoom() {
final room = Matrix.of(context).client.getRoomById(selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setFavourite(!room.isFavourite),
);
}
Future<void> toggleMuted() {
final room = Matrix.of(context).client.getRoomById(selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentionsOnly
: PushRuleState.notify),
);
}
Future<void> archiveAction() async {
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) ==
OkCancelResult.ok;
if (!confirmed) return;
await showFutureLoadingDialog(
context: context,
future: () => _archiveSelectedRooms(),
);
setState(() => null);
}
void setStatus() async {
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).setStatus,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: L10n.of(context).statusExampleMessage,
),
]);
if (input == null) return;
await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.setPresence(
Matrix.of(context).client.userID,
PresenceType.online,
statusMsg: input.single,
),
);
}
void onPopupMenuSelect(action) {
switch (action) {
case PopupMenuAction.setStatus:
setStatus();
break;
case PopupMenuAction.settings:
AdaptivePageLayout.of(context).pushNamed('/settings');
break;
case PopupMenuAction.invite:
FluffyShare.share(
L10n.of(context).inviteText(Matrix.of(context).client.userID,
'https://matrix.to/#/${Matrix.of(context).client.userID}'),
context);
break;
case PopupMenuAction.newGroup:
AdaptivePageLayout.of(context).pushNamed('/newgroup');
break;
case PopupMenuAction.archive:
AdaptivePageLayout.of(context).pushNamed('/archive');
break;
}
}
Future<void> _archiveSelectedRooms() async {
final client = Matrix.of(context).client;
while (selectedRoomIds.isNotEmpty) {
final roomId = selectedRoomIds.first;
await client.getRoomById(roomId).leave();
toggleSelection(roomId);
}
}
Future<void> waitForFirstSync() async {
final client = Matrix.of(context).client;
if (client.prevBatch?.isEmpty ?? true) {
await client.onFirstSync.stream.first;
}
return true;
}
void cancelAction() {
final selectMode = Matrix.of(context).shareContent != null
? SelectMode.share
: selectedRoomIds.isEmpty
? SelectMode.normal
: SelectMode.select;
if (selectMode == SelectMode.share) {
setState(() => Matrix.of(context).shareContent = null);
} else {
setState(() => selectedRoomIds.clear());
}
}
@override
Widget build(BuildContext context) => ChatListUI(this);
}
enum ChatListPopupMenuItemActions {
createGroup,
discover,
setStatus,
inviteContact,
settings,
}

View file

@ -0,0 +1,96 @@
import 'dart:developer';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/pages/views/chat_permissions_settings_ui.dart';
import 'package:fluffychat/pages/permission_slider_dialog.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:famedlysdk/famedlysdk.dart';
class ChatPermissionsSettings extends StatefulWidget {
final String roomId;
const ChatPermissionsSettings(this.roomId, {Key key}) : super(key: key);
@override
ChatPermissionsSettingsController createState() =>
ChatPermissionsSettingsController();
}
class ChatPermissionsSettingsController extends State<ChatPermissionsSettings> {
void editPowerLevel(BuildContext context, String key, int currentLevel,
{String category}) async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
if (!room.canSendEvent(EventTypes.RoomPowerLevels)) {
AdaptivePageLayout.of(context)
.showSnackBar(SnackBar(content: Text(L10n.of(context).noPermission)));
return;
}
final newLevel =
await PermissionSliderDialog(initialPermission: currentLevel)
.show(context);
if (newLevel == null) return;
final content = Map<String, dynamic>.from(
room.getState(EventTypes.RoomPowerLevels).content);
if (category != null) {
if (!content.containsKey(category)) {
content[category] = <String, dynamic>{};
}
content[category][key] = newLevel;
} else {
content[key] = newLevel;
}
inspect(content);
await showFutureLoadingDialog(
context: context,
future: () => room.client
.setRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, content),
);
}
Stream get onChanged => Matrix.of(context).client.onSync.stream.where(
(e) =>
(e?.rooms?.join?.containsKey(widget.roomId) ?? false) &&
(e.rooms.join[widget.roomId]?.timeline?.events
?.any((s) => s.type == EventTypes.RoomPowerLevels) ??
false),
);
void updateRoomAction(ServerCapabilities capabilities) async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final String roomVersion =
room.getState(EventTypes.RoomCreate).content['room_version'] ?? '1';
final newVersion = await showConfirmationDialog<String>(
context: context,
title: L10n.of(context).replaceRoomWithNewerVersion,
actions: capabilities.mRoomVersions.available.entries
.where((r) => r.key != roomVersion)
.map((version) => AlertDialogAction(
key: version.key,
label:
'${version.key} (${version.value.toString().split('.').last})'))
.toList(),
);
if (newVersion == null ||
OkCancelResult.cancel ==
await showOkCancelAlertDialog(
context: context,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
title: L10n.of(context).areYouSure,
)) {
return;
}
await showFutureLoadingDialog(
context: context,
future: () => room.client.upgradeRoom(widget.roomId, newVersion),
).then((_) => AdaptivePageLayout.of(context).pop());
}
@override
Widget build(BuildContext context) => ChatPermissionsSettingsUI(this);
}

View file

@ -0,0 +1,140 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/encryption/utils/key_verification.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/views/device_settings_ui.dart';
import 'package:fluffychat/pages/key_verification_dialog.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../widgets/matrix.dart';
class DevicesSettings extends StatefulWidget {
@override
DevicesSettingsController createState() => DevicesSettingsController();
}
class DevicesSettingsController extends State<DevicesSettings> {
List<Device> devices;
Future<bool> loadUserDevices(BuildContext context) async {
if (devices != null) return true;
devices = await Matrix.of(context).client.getDevices();
return true;
}
void reload() => setState(() => devices = null);
bool loadingDeletingDevices = false;
String errorDeletingDevices;
void removeDevicesAction(List<Device> devices) async {
if (await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) ==
OkCancelResult.cancel) return;
final matrix = Matrix.of(context);
final deviceIds = <String>[];
for (final userDevice in devices) {
deviceIds.add(userDevice.deviceId);
}
try {
setState(() {
loadingDeletingDevices = true;
errorDeletingDevices = null;
});
await matrix.client.uiaRequestBackground(
(auth) => matrix.client.deleteDevices(
deviceIds,
auth: auth,
),
);
reload();
} catch (e, s) {
Logs().v('Error while deleting devices', e, s);
setState(() => errorDeletingDevices = e.toString());
} finally {
setState(() => loadingDeletingDevices = false);
}
}
void renameDeviceAction(Device device) async {
final displayName = await showTextInputDialog(
context: context,
title: L10n.of(context).changeDeviceName,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: device.displayName,
)
],
);
if (displayName == null) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context)
.client
.updateDevice(device.deviceId, displayName: displayName.single),
);
if (success.error == null) {
reload();
}
}
void verifyDeviceAction(Device device) async {
final req = Matrix.of(context)
.client
.userDeviceKeys[Matrix.of(context).client.userID]
.deviceKeys[device.deviceId]
.startVerification();
req.onUpdate = () {
if ({KeyVerificationState.error, KeyVerificationState.done}
.contains(req.state)) {
setState(() => null);
}
};
await KeyVerificationDialog(request: req).show(context);
}
void blockDeviceAction(Device device) async {
final key = Matrix.of(context)
.client
.userDeviceKeys[Matrix.of(context).client.userID]
.deviceKeys[device.deviceId];
if (key.directVerified) {
await key.setVerified(false);
}
await key.setBlocked(true);
setState(() => null);
}
void unblockDeviceAction(Device device) async {
final key = Matrix.of(context)
.client
.userDeviceKeys[Matrix.of(context).client.userID]
.deviceKeys[device.deviceId];
await key.setBlocked(false);
setState(() => null);
}
bool _isOwnDevice(Device userDevice) =>
userDevice.deviceId == Matrix.of(context).client.deviceID;
Device get thisDevice => devices.firstWhere(
_isOwnDevice,
orElse: () => null,
);
List<Device> get notThisDevice => List<Device>.from(devices)
..removeWhere(_isOwnDevice)
..sort((a, b) => b.lastSeenTs.compareTo(a.lastSeenTs));
@override
Widget build(BuildContext context) => DevicesSettingsUI(this);
}

View file

@ -0,0 +1,138 @@
import 'dart:async';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/views/homeserver_picker_ui.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:uni_links/uni_links.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import '../main.dart';
import '../utils/localized_exception_extension.dart';
import 'package:universal_html/html.dart' as html;
class HomeserverPicker extends StatefulWidget {
@override
HomeserverPickerController createState() => HomeserverPickerController();
}
class HomeserverPickerController extends State<HomeserverPicker> {
bool isLoading = false;
String domain = AppConfig.defaultHomeserver;
final TextEditingController homeserverController =
TextEditingController(text: AppConfig.defaultHomeserver);
StreamSubscription _intentDataStreamSubscription;
void _loginWithToken(String token) {
if (token?.isEmpty ?? true) return;
showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.login(
type: AuthenticationTypes.token,
token: token,
initialDeviceDisplayName: PlatformInfos.clientName,
),
);
}
void _processIncomingUris(String text) async {
if (text == null || !text.startsWith(AppConfig.appOpenUrlScheme)) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
final token = Uri.parse(text).queryParameters['loginToken'];
if (token != null) _loginWithToken(token);
}
void _initReceiveUri() {
if (!PlatformInfos.isMobile) return;
// For receiving shared Uris
_intentDataStreamSubscription = linkStream.listen(_processIncomingUris);
if (FluffyChatApp.gotInitialLink == false) {
FluffyChatApp.gotInitialLink = true;
getInitialLink().then(_processIncomingUris);
}
}
@override
void initState() {
super.initState();
_initReceiveUri();
if (kIsWeb) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final token =
Uri.parse(html.window.location.href).queryParameters['loginToken'];
_loginWithToken(token);
});
}
}
@override
void dispose() {
super.dispose();
_intentDataStreamSubscription?.cancel();
}
/// Starts an analysis of the given homeserver. It uses the current domain and
/// makes sure that it is prefixed with https. Then it searches for the
/// well-known information and forwards to the login page depending on the
/// login type. For SSO login only the app opens the page and otherwise it
/// forwards to the route `/signup`.
void checkHomeserverAction() async {
try {
if (domain.isEmpty) throw L10n.of(context).changeTheHomeserver;
var homeserver = domain;
if (!homeserver.startsWith('https://')) {
homeserver = 'https://$homeserver';
}
setState(() => isLoading = true);
final wellKnown =
await Matrix.of(context).client.checkHomeserver(homeserver);
var jitsi = wellKnown?.content
?.tryGet<Map<String, dynamic>>('im.vector.riot.jitsi')
?.tryGet<String>('preferredDomain');
if (jitsi != null) {
if (!jitsi.endsWith('/')) {
jitsi += '/';
}
Logs().v('Found custom jitsi instance $jitsi');
await Matrix.of(context)
.store
.setItem(SettingKeys.jitsiInstance, jitsi);
AppConfig.jitsiInstance = jitsi;
}
final loginTypes = await Matrix.of(context).client.getLoginFlows();
if (loginTypes.flows
.any((flow) => flow.type == AuthenticationTypes.password)) {
await AdaptivePageLayout.of(context)
.pushNamed(AppConfig.enableRegistration ? '/signup' : '/login');
} else if (loginTypes.flows
.any((flow) => flow.type == AuthenticationTypes.sso)) {
final redirectUrl = kIsWeb
? html.window.location.href
: AppConfig.appOpenUrlScheme.toLowerCase() + '://sso';
await launch(
'${Matrix.of(context).client.homeserver?.toString()}/_matrix/client/r0/login/sso/redirect?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}');
}
} catch (e) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text((e as Object).toLocalizedString(context))));
} finally {
if (mounted) {
setState(() => isLoading = false);
}
}
}
@override
Widget build(BuildContext context) => HomeserverPickerUI(this);
}

View file

@ -0,0 +1,42 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/pages/views/image_viewer_ui.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import '../utils/event_extension.dart';
class ImageViewer extends StatefulWidget {
final Event event;
final void Function() onLoaded;
const ImageViewer(this.event, {Key key, this.onLoaded}) : super(key: key);
@override
ImageViewerController createState() => ImageViewerController();
}
class ImageViewerController extends State<ImageViewer> {
/// Forward this image to another room.
void forwardAction() {
Matrix.of(context).shareContent = widget.event.content;
AdaptivePageLayout.of(context).popUntilIsFirst();
}
/// Open this file with a system call.
void openFileAction() => widget.event.openFile(context, downloadOnly: true);
/// Go back if user swiped it away
void onInteractionEnds(ScaleEndDetails endDetails) {
if (PlatformInfos.usesTouchscreen == false) {
if (endDetails.velocity.pixelsPerSecond.dy >
MediaQuery.of(context).size.height * 1.50) {
Navigator.of(context, rootNavigator: false).pop();
}
}
}
@override
Widget build(BuildContext context) => ImageViewerUI(this);
}

View file

@ -0,0 +1,120 @@
import 'dart:async';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/views/invitation_selection_ui.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../utils/localized_exception_extension.dart';
class InvitationSelection extends StatefulWidget {
final String roomId;
const InvitationSelection(this.roomId, {Key key}) : super(key: key);
@override
InvitationSelectionController createState() =>
InvitationSelectionController();
}
class InvitationSelectionController extends State<InvitationSelection> {
TextEditingController controller = TextEditingController();
String currentSearchTerm;
bool loading = false;
List<Profile> foundProfiles = [];
Timer coolDown;
Future<List<User>> getContacts(BuildContext context) async {
final client = Matrix.of(context).client;
final room = client.getRoomById(widget.roomId);
final participants = await room.requestParticipants();
participants.removeWhere(
(u) => ![Membership.join, Membership.invite].contains(u.membership),
);
final contacts = <User>[];
final userMap = <String, bool>{};
for (var i = 0; i < client.rooms.length; i++) {
final roomUsers = client.rooms[i].getParticipants();
for (var j = 0; j < roomUsers.length; j++) {
if (userMap[roomUsers[j].id] != true &&
participants.indexWhere((u) => u.id == roomUsers[j].id) == -1) {
contacts.add(roomUsers[j]);
}
userMap[roomUsers[j].id] = true;
}
}
contacts.sort(
(a, b) => a.calcDisplayname().toLowerCase().compareTo(
b.calcDisplayname().toLowerCase(),
),
);
return contacts;
}
void inviteAction(BuildContext context, String id) async {
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final success = await showFutureLoadingDialog(
context: context,
future: () => room.invite(id),
);
if (success.error == null) {
AdaptivePageLayout.of(context).showSnackBar(SnackBar(
content: Text(L10n.of(context).contactHasBeenInvitedToTheGroup)));
}
}
void searchUserWithCoolDown(String text) async {
coolDown?.cancel();
coolDown = Timer(
Duration(seconds: 1),
() => searchUser(context, text),
);
}
void searchUser(BuildContext context, String text) async {
coolDown?.cancel();
if (text.isEmpty) {
setState(() => foundProfiles = []);
}
currentSearchTerm = text;
if (currentSearchTerm.isEmpty) return;
if (loading) return;
setState(() => loading = true);
final matrix = Matrix.of(context);
UserSearchResult response;
try {
response = await matrix.client.searchUserDirectory(text, limit: 10);
} catch (e) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text((e as Object).toLocalizedString(context))));
return;
} finally {
setState(() => loading = false);
}
setState(() {
foundProfiles = List<Profile>.from(response.results);
if (text.isValidMatrixId &&
foundProfiles.indexWhere((profile) => text == profile.userId) == -1) {
setState(() => foundProfiles = [
Profile.fromJson({'user_id': text}),
]);
}
final participants = Matrix.of(context)
.client
.getRoomById(widget.roomId)
.getParticipants()
.where((user) =>
[Membership.join, Membership.invite].contains(user.membership))
.toList();
foundProfiles.removeWhere((profile) =>
participants.indexWhere((u) => u.id == profile.userId) != -1);
});
}
@override
Widget build(BuildContext context) => InvitationSelectionUI(this);
}

View file

@ -0,0 +1,395 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/encryption.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../widgets/adaptive_flat_button.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import '../utils/string_color.dart';
import '../utils/beautify_string_extension.dart';
class KeyVerificationDialog extends StatefulWidget {
Future<void> show(BuildContext context) => PlatformInfos.isCupertinoStyle
? showCupertinoDialog(
context: context,
barrierDismissible: true,
builder: (context) => this,
useRootNavigator: false,
)
: showDialog(
context: context,
barrierDismissible: true,
builder: (context) => this,
useRootNavigator: false,
);
final KeyVerification request;
KeyVerificationDialog({
this.request,
});
@override
_KeyVerificationPageState createState() => _KeyVerificationPageState();
}
class _KeyVerificationPageState extends State<KeyVerificationDialog> {
void Function() originalOnUpdate;
final _scrollController = ScrollController();
@override
void initState() {
originalOnUpdate = widget.request.onUpdate;
widget.request.onUpdate = () {
if (originalOnUpdate != null) {
originalOnUpdate();
}
setState(() => null);
};
widget.request.client.getProfileFromUserId(widget.request.userId).then((p) {
profile = p;
setState(() => null);
});
super.initState();
}
@override
void dispose() {
widget.request.onUpdate =
originalOnUpdate; // don't want to get updates anymore
if (![KeyVerificationState.error, KeyVerificationState.done]
.contains(widget.request.state)) {
widget.request.cancel('m.user');
}
_scrollController.dispose();
super.dispose();
}
Profile profile;
@override
Widget build(BuildContext context) {
Widget body;
final buttons = <Widget>[];
switch (widget.request.state) {
case KeyVerificationState.askSSSS:
// prompt the user for their ssss passphrase / key
final textEditingController = TextEditingController();
String input;
final checkInput = () async {
if (input == null || input.isEmpty) {
return;
}
final valid = await showFutureLoadingDialog(
context: context,
future: () async {
// make sure the loading spinner shows before we test the keys
await Future.delayed(Duration(milliseconds: 100));
var valid = false;
try {
await widget.request.openSSSS(keyOrPassphrase: input);
valid = true;
} catch (_) {
valid = false;
}
return valid;
});
if (valid.error != null) {
await showOkAlertDialog(
context: context,
message: L10n.of(context).incorrectPassphraseOrKey,
useRootNavigator: false,
);
}
};
body = Container(
margin: EdgeInsets.only(left: 8.0, right: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(L10n.of(context).askSSSSSign,
style: TextStyle(fontSize: 20)),
Container(height: 10),
TextField(
controller: textEditingController,
autofocus: false,
autocorrect: false,
onSubmitted: (s) {
input = s;
checkInput();
},
minLines: 1,
maxLines: 1,
obscureText: true,
decoration: InputDecoration(
hintText: L10n.of(context).passphraseOrKey,
prefixStyle: TextStyle(color: Theme.of(context).primaryColor),
suffixStyle: TextStyle(color: Theme.of(context).primaryColor),
border: OutlineInputBorder(),
),
),
],
),
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).submit,
onPressed: () {
input = textEditingController.text;
checkInput();
},
));
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).skip,
onPressed: () => widget.request.openSSSS(skip: true),
));
break;
case KeyVerificationState.askAccept:
body = Container(
margin: EdgeInsets.only(left: 8.0, right: 8.0),
child: Text(
L10n.of(context).askVerificationRequest(widget.request.userId),
style: TextStyle(fontSize: 20)),
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).accept,
onPressed: () => widget.request.acceptVerification(),
));
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).reject,
onPressed: () {
widget.request.rejectVerification().then((_) {
Navigator.of(context, rootNavigator: false).pop();
});
},
));
break;
case KeyVerificationState.waitingAccept:
body = Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
PlatformInfos.isCupertinoStyle
? CupertinoActivityIndicator()
: CircularProgressIndicator(),
SizedBox(height: 10),
Text(
L10n.of(context).waitingPartnerAcceptRequest,
textAlign: TextAlign.center,
),
],
);
final key = widget.request.client.userDeviceKeys[widget.request.userId]
.deviceKeys[widget.request.deviceId];
if (key != null) {
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).verifyManual,
onPressed: () async {
final result = await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
title: L10n.of(context).verifyManual,
message: key.ed25519Key.beautified,
);
if (result == OkCancelResult.ok) {
await key.setVerified(true);
}
await widget.request.cancel();
Navigator.of(context, rootNavigator: false).pop();
},
));
}
break;
case KeyVerificationState.askSas:
TextSpan compareWidget;
// maybe add a button to switch between the two and only determine default
// view for if "emoji" is a present sasType or not?
String compareText;
if (widget.request.sasTypes.contains('emoji')) {
compareText = L10n.of(context).compareEmojiMatch;
compareWidget = TextSpan(
children: widget.request.sasEmojis
.map((e) => WidgetSpan(child: _Emoji(e)))
.toList(),
);
} else {
compareText = L10n.of(context).compareNumbersMatch;
final numbers = widget.request.sasNumbers;
final numbstr = '${numbers[0]}-${numbers[1]}-${numbers[2]}';
compareWidget =
TextSpan(text: numbstr, style: TextStyle(fontSize: 40));
}
body = Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Center(
child: Text(
compareText,
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
),
SizedBox(height: 10),
Text.rich(
compareWidget,
textAlign: TextAlign.center,
),
],
);
buttons.add(AdaptiveFlatButton(
textColor: Colors.red,
label: L10n.of(context).theyDontMatch,
onPressed: () => widget.request.rejectSas(),
));
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).theyMatch,
onPressed: () => widget.request.acceptSas(),
));
break;
case KeyVerificationState.waitingSas:
final acceptText = widget.request.sasTypes.contains('emoji')
? L10n.of(context).waitingPartnerEmoji
: L10n.of(context).waitingPartnerNumbers;
body = Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
PlatformInfos.isCupertinoStyle
? CupertinoActivityIndicator()
: CircularProgressIndicator(),
SizedBox(height: 10),
Text(
acceptText,
textAlign: TextAlign.center,
),
],
);
break;
case KeyVerificationState.done:
body = Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.check_circle_outlined, color: Colors.green, size: 200.0),
SizedBox(height: 10),
Text(
L10n.of(context).verifySuccess,
textAlign: TextAlign.center,
),
],
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).close,
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
));
break;
case KeyVerificationState.error:
body = Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.cancel, color: Colors.red, size: 200.0),
SizedBox(height: 10),
Text(
'Error ${widget.request.canceledCode}: ${widget.request.canceledReason}',
textAlign: TextAlign.center,
),
],
);
buttons.add(AdaptiveFlatButton(
label: L10n.of(context).close,
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
));
break;
}
body ??= Text('ERROR: Unknown state ' + widget.request.state.toString());
final otherName = profile?.displayname ?? widget.request.userId;
var bottom;
if (widget.request.deviceId != null) {
final deviceName = widget
.request
.client
.userDeviceKeys[widget.request.userId]
?.deviceKeys[widget.request.deviceId]
?.deviceDisplayName ??
'';
bottom = Container(
alignment: Alignment.center,
padding: EdgeInsets.all(16.0),
child: Text('$deviceName (${widget.request.deviceId})',
style: TextStyle(color: Theme.of(context).textTheme.caption.color)),
);
}
final userNameTitle = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
otherName,
maxLines: 1,
style: TextStyle(
color: otherName.color,
fontWeight: FontWeight.bold,
),
),
if (otherName != widget.request.userId)
Text(
' - ' + widget.request.userId,
maxLines: 1,
style: TextStyle(
fontStyle: FontStyle.italic,
),
),
],
);
final title = Text(L10n.of(context).verifyTitle);
final content = Scrollbar(
isAlwaysShown: true,
controller: _scrollController,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
controller: _scrollController,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (PlatformInfos.isCupertinoStyle) ...[
SizedBox(height: 8),
Center(child: userNameTitle),
SizedBox(height: 12),
],
body,
if (bottom != null) bottom,
],
),
),
);
if (PlatformInfos.isCupertinoStyle) {
return CupertinoAlertDialog(
title: title,
content: content,
actions: buttons,
);
}
return AlertDialog(
title: title,
content: content,
actions: buttons,
);
}
}
class _Emoji extends StatelessWidget {
final KeyVerificationEmoji emoji;
_Emoji(this.emoji);
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(emoji.emoji, style: TextStyle(fontSize: 50)),
Text(emoji.name),
Container(height: 10, width: 5),
],
);
}
}

44
lib/pages/new_group.dart Normal file
View file

@ -0,0 +1,44 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart' as sdk;
import 'package:fluffychat/pages/views/new_group_ui.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class NewGroup extends StatefulWidget {
@override
NewGroupController createState() => NewGroupController();
}
class NewGroupController extends State<NewGroup> {
TextEditingController controller = TextEditingController();
bool publicGroup = false;
void setPublicGroup(bool b) => setState(() => publicGroup = b);
void submitAction([_]) async {
final matrix = Matrix.of(context);
final roomID = await showFutureLoadingDialog(
context: context,
future: () => matrix.client.createRoom(
preset: publicGroup
? sdk.CreateRoomPreset.public_chat
: sdk.CreateRoomPreset.private_chat,
visibility: publicGroup ? sdk.Visibility.public : null,
roomAliasName: publicGroup && controller.text.isNotEmpty
? controller.text.trim().toLowerCase().replaceAll(' ', '_')
: null,
name: controller.text.isNotEmpty ? controller.text : null,
),
);
AdaptivePageLayout.of(context).popUntilIsFirst();
if (roomID != null) {
await AdaptivePageLayout.of(context).pushNamed('/rooms/${roomID.result}');
await AdaptivePageLayout.of(context)
.pushNamed('/rooms/${roomID.result}/invite');
}
}
@override
Widget build(BuildContext context) => NewGroupUI(this);
}

View file

@ -0,0 +1,116 @@
import 'dart:async';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/pages/views/new_private_chat_ui.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class NewPrivateChat extends StatefulWidget {
@override
NewPrivateChatController createState() => NewPrivateChatController();
}
class NewPrivateChatController extends State<NewPrivateChat> {
TextEditingController controller = TextEditingController();
final formKey = GlobalKey<FormState>();
bool loading = false;
String currentSearchTerm;
List<Profile> foundProfiles = [];
Timer coolDown;
Profile get foundProfile =>
foundProfiles.firstWhere((user) => user.userId == '@$currentSearchTerm',
orElse: () => null);
bool get correctMxId =>
foundProfiles
.indexWhere((user) => user.userId == '@$currentSearchTerm') !=
-1;
void submitAction([_]) async {
controller.text = controller.text.replaceAll('@', '').trim();
if (controller.text.isEmpty) return;
if (!formKey.currentState.validate()) return;
final matrix = Matrix.of(context);
if ('@' + controller.text == matrix.client.userID) return;
final user = User(
'@' + controller.text,
room: Room(id: '', client: matrix.client),
);
final roomID = await showFutureLoadingDialog(
context: context,
future: () => user.startDirectChat(),
);
if (roomID.error == null) {
await AdaptivePageLayout.of(context)
.popAndPushNamed('/rooms/${roomID.result}');
}
}
void searchUserWithCoolDown([_]) async {
coolDown?.cancel();
coolDown = Timer(
Duration(milliseconds: 500),
() => searchUser(controller.text),
);
}
void searchUser(String text) async {
if (text.isEmpty) {
setState(() {
foundProfiles = [];
});
}
currentSearchTerm = text;
if (currentSearchTerm.isEmpty) return;
if (loading) return;
setState(() => loading = true);
final matrix = Matrix.of(context);
UserSearchResult response;
try {
response = await matrix.client.searchUserDirectory(text, limit: 10);
} catch (_) {}
setState(() => loading = false);
if (response?.results?.isEmpty ?? true) return;
setState(() {
foundProfiles = List<Profile>.from(response.results);
});
}
String validateForm(String value) {
if (value.isEmpty) {
return L10n.of(context).pleaseEnterAMatrixIdentifier;
}
final matrix = Matrix.of(context);
final mxid = '@' + controller.text.trim();
if (mxid == matrix.client.userID) {
return L10n.of(context).youCannotInviteYourself;
}
if (!mxid.contains('@')) {
return L10n.of(context).makeSureTheIdentifierIsValid;
}
if (!mxid.contains(':')) {
return L10n.of(context).makeSureTheIdentifierIsValid;
}
return null;
}
void inviteAction() => FluffyShare.share(
L10n.of(context).inviteText(Matrix.of(context).client.userID,
'https://matrix.to/#/${Matrix.of(context).client.userID}'),
context,
);
void pickUser(Profile foundProfile) => setState(
() => controller.text =
currentSearchTerm = foundProfile.userId.substring(1),
);
@override
Widget build(BuildContext context) => NewPrivateChatUI(this);
}

View file

@ -0,0 +1,97 @@
import 'package:fluffychat/widgets/adaptive_flat_button.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class PermissionSliderDialog extends StatefulWidget {
const PermissionSliderDialog({
Key key,
this.initialPermission = 0,
}) : super(key: key);
Future<int> show(BuildContext context) => PlatformInfos.isCupertinoStyle
? showCupertinoDialog<int>(
context: context,
builder: (context) => this,
useRootNavigator: false,
)
: showDialog<int>(
context: context,
builder: (context) => this,
useRootNavigator: false,
);
final int initialPermission;
@override
_PermissionSliderDialogState createState() => _PermissionSliderDialogState();
}
class _PermissionSliderDialogState extends State<PermissionSliderDialog> {
int _permission;
@override
void initState() {
_permission = widget.initialPermission;
super.initState();
}
@override
Widget build(BuildContext context) {
final slider = PlatformInfos.isCupertinoStyle
? CupertinoSlider(
value: _permission.toDouble(),
onChanged: (d) => setState(() => _permission = d.round()),
max: 100.0,
min: 0.0,
)
: Slider(
value: _permission.toDouble(),
onChanged: (d) => setState(() => _permission = d.round()),
max: 100.0,
min: 0.0,
);
final title = Text(
L10n.of(context).setPermissionsLevel,
textAlign: TextAlign.center,
);
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Level: ' +
(_permission == 100
? '$_permission (${L10n.of(context).admin})'
: _permission >= 50
? '$_permission (${L10n.of(context).moderator})'
: _permission.toString())),
Container(
height: 56,
child: slider,
),
],
);
final buttons = [
AdaptiveFlatButton(
label: L10n.of(context).cancel,
onPressed: () =>
Navigator.of(context, rootNavigator: false).pop<int>(null),
),
AdaptiveFlatButton(
label: L10n.of(context).confirm,
onPressed: () =>
Navigator.of(context, rootNavigator: false).pop<int>(_permission),
),
];
if (PlatformInfos.isCupertinoStyle) {
return CupertinoAlertDialog(
title: title,
content: content,
actions: buttons,
);
}
return AlertDialog(
title: title,
content: content,
actions: buttons,
);
}
}

View file

@ -0,0 +1,128 @@
import 'dart:async';
import 'package:fluffychat/utils/sentry_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
class RecordingDialog extends StatefulWidget {
static const String recordingFileType = 'aac';
const RecordingDialog({
Key key,
}) : super(key: key);
@override
_RecordingDialogState createState() => _RecordingDialogState();
}
class _RecordingDialogState extends State<RecordingDialog> {
Timer _recorderSubscription;
Duration _duration = Duration.zero;
bool error = false;
String _recordedPath;
void startRecording() async {
try {
final tempDir = await getTemporaryDirectory();
_recordedPath =
'${tempDir.path}/recording${DateTime.now().microsecondsSinceEpoch}.${RecordingDialog.recordingFileType}';
final result = await Record.hasPermission();
if (result != true) {
setState(() => error = true);
return;
}
await Record.start(path: _recordedPath, encoder: AudioEncoder.AAC);
setState(() => _duration = Duration.zero);
_recorderSubscription?.cancel();
_recorderSubscription = Timer.periodic(Duration(seconds: 1),
(_) => setState(() => _duration += Duration(seconds: 1)));
} catch (e, s) {
SentryController.captureException(e, s);
setState(() => error = true);
}
}
@override
void initState() {
super.initState();
startRecording();
}
@override
void dispose() {
_recorderSubscription?.cancel();
Record.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
const maxDecibalWidth = 64.0;
final decibalWidth =
((_duration.inSeconds % 2) + 1) * (maxDecibalWidth / 4).toDouble();
final time =
'${_duration.inMinutes.toString().padLeft(2, '0')}:${(_duration.inSeconds % 60).toString().padLeft(2, '0')}';
return AlertDialog(
content: error
? Text(L10n.of(context).oopsSomethingWentWrong)
: Row(
children: <Widget>[
Container(
width: maxDecibalWidth,
height: maxDecibalWidth,
alignment: Alignment.center,
child: AnimatedContainer(
duration: Duration(seconds: 1),
width: decibalWidth,
height: decibalWidth,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(decibalWidth),
),
),
),
SizedBox(width: 8),
Expanded(
child: Text(
'${L10n.of(context).recording}: $time',
style: TextStyle(
fontSize: 18,
),
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
child: Text(
L10n.of(context).cancel.toUpperCase(),
style: TextStyle(
color: Theme.of(context).textTheme.bodyText2.color.withAlpha(150),
),
),
),
if (error != true)
TextButton(
onPressed: () async {
_recorderSubscription?.cancel();
await Record.stop();
Navigator.of(context, rootNavigator: false)
.pop<String>(_recordedPath);
},
child: Row(
children: <Widget>[
Text(L10n.of(context).send.toUpperCase()),
SizedBox(width: 4),
Icon(Icons.send_outlined, size: 15),
],
),
),
],
);
}
}

143
lib/pages/search.dart Normal file
View file

@ -0,0 +1,143 @@
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:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'views/search_ui.dart';
class Search extends StatefulWidget {
final String alias;
const Search({Key key, this.alias}) : super(key: key);
@override
SearchController createState() => SearchController();
}
class SearchController extends State<Search> {
final TextEditingController controller = TextEditingController();
Future<PublicRoomsResponse> publicRoomsResponse;
String lastServer;
Timer _coolDown;
String genericSearchTerm;
void search(String query) async {
setState(() => null);
_coolDown?.cancel();
_coolDown = Timer(
Duration(milliseconds: 500),
() => setState(() {
genericSearchTerm = query;
publicRoomsResponse = null;
searchUser(context, controller.text);
}),
);
}
Future<String> _joinRoomAndWait(
BuildContext context,
String roomId,
String alias,
) async {
if (Matrix.of(context).client.getRoomById(roomId) != null) {
return roomId;
}
final newRoomId = await Matrix.of(context)
.client
.joinRoom(alias?.isNotEmpty ?? false ? alias : roomId);
await Matrix.of(context)
.client
.onRoomUpdate
.stream
.firstWhere((r) => r.id == newRoomId);
return newRoomId;
}
void joinGroupAction(PublicRoom room) async {
if (await showOkCancelAlertDialog(
context: context,
okLabel: L10n.of(context).joinRoom,
title: '${room.name} (${room.numJoinedMembers ?? 0})',
message: room.topic ?? L10n.of(context).noDescription,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) ==
OkCancelResult.cancel) {
return;
}
final success = await showFutureLoadingDialog(
context: context,
future: () => _joinRoomAndWait(
context,
room.roomId,
room.canonicalAlias ?? room.aliases.first,
),
);
if (success.error == null) {
await AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/rooms/${success.result}');
}
}
String server;
void setServer() async {
final newServer = await showTextInputDialog(
title: L10n.of(context).changeTheHomeserver,
context: context,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
prefixText: 'https://',
hintText: Matrix.of(context).client.homeserver.host,
initialText: server,
keyboardType: TextInputType.url,
)
]);
if (newServer == null) return;
setState(() {
server = newServer.single;
});
}
String currentSearchTerm;
List<Profile> foundProfiles = [];
void searchUser(BuildContext context, String text) async {
if (text.isEmpty) {
setState(() {
foundProfiles = [];
});
}
currentSearchTerm = text;
if (currentSearchTerm.isEmpty) return;
final matrix = Matrix.of(context);
UserSearchResult response;
try {
response = await matrix.client.searchUserDirectory(text, limit: 10);
} catch (_) {}
foundProfiles = List<Profile>.from(response?.results ?? []);
if (foundProfiles.isEmpty && text.isValidMatrixId && text.sigil == '@') {
foundProfiles.add(Profile.fromJson({
'displayname': text.localpart,
'user_id': text,
}));
}
setState(() {});
}
@override
void initState() {
genericSearchTerm = widget.alias;
super.initState();
}
@override
Widget build(BuildContext context) => SearchUI(this);
}

View file

@ -0,0 +1,103 @@
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 '../utils/matrix_file_extension.dart';
import '../utils/room_send_file_extension.dart';
import '../utils/resize_image.dart';
class SendFileDialog extends StatefulWidget {
final Room room;
final MatrixFile file;
const SendFileDialog({
this.room,
this.file,
Key key,
}) : super(key: key);
@override
_SendFileDialogState createState() => _SendFileDialogState();
}
class _SendFileDialogState extends State<SendFileDialog> {
bool origImage = false;
bool _isSending = false;
Future<void> _send() async {
var file = widget.file;
if (file is MatrixImageFile && !origImage) {
try {
file = await resizeImage(file, max: 1600);
} catch (e) {
// couldn't resize
}
}
await widget.room.sendFileEventWithThumbnail(file);
}
@override
Widget build(BuildContext context) {
var sendStr = L10n.of(context).sendFile;
if (widget.file is MatrixImageFile) {
sendStr = L10n.of(context).sendImage;
} else if (widget.file is MatrixAudioFile) {
sendStr = L10n.of(context).sendAudio;
} else if (widget.file is MatrixVideoFile) {
sendStr = L10n.of(context).sendVideo;
}
Widget contentWidget;
if (widget.file is MatrixImageFile) {
contentWidget = Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Flexible(
child: Image.memory(
widget.file.bytes,
fit: BoxFit.contain,
),
),
Text(widget.file.name),
Row(
children: <Widget>[
Checkbox(
value: origImage,
onChanged: (v) => setState(() => origImage = v),
),
InkWell(
onTap: () => setState(() => origImage = !origImage),
child: Text(L10n.of(context).sendOriginal +
' (${widget.file.sizeString})'),
),
],
)
]);
} else {
contentWidget = Text('${widget.file.name} (${widget.file.sizeString})');
}
return AlertDialog(
title: Text(sendStr),
content: contentWidget,
actions: <Widget>[
TextButton(
onPressed: () {
// just close the dialog
Navigator.of(context, rootNavigator: false).pop();
},
child: Text(L10n.of(context).cancel),
),
TextButton(
onPressed: _isSending
? null
: () async {
setState(() {
_isSending = true;
});
await showFutureLoadingDialog(
context: context, future: () => _send());
Navigator.of(context, rootNavigator: false).pop();
},
child: Text(L10n.of(context).send),
),
],
);
}
}

378
lib/pages/settings.dart Normal file
View file

@ -0,0 +1,378 @@
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:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/sentry_controller.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:image_picker/image_picker.dart';
import 'views/settings_ui.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'bootstrap_dialog.dart';
import '../widgets/matrix.dart';
import '../config/app_config.dart';
import '../config/setting_keys.dart';
class Settings extends StatefulWidget {
@override
SettingsController createState() => SettingsController();
}
class SettingsController extends State<Settings> {
Future<dynamic> profileFuture;
dynamic profile;
Future<bool> crossSigningCachedFuture;
bool crossSigningCached;
Future<bool> megolmBackupCachedFuture;
bool megolmBackupCached;
void logoutAction() async {
if (await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSureYouWantToLogout,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) ==
OkCancelResult.cancel) {
return;
}
final matrix = Matrix.of(context);
await showFutureLoadingDialog(
context: context,
future: () => matrix.client.logout(),
);
}
void changePasswordAccountAction() async {
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).changePassword,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: L10n.of(context).pleaseEnterYourPassword,
obscureText: true,
minLines: 1,
maxLines: 1,
),
DialogTextField(
hintText: L10n.of(context).chooseAStrongPassword,
obscureText: true,
minLines: 1,
maxLines: 1,
),
],
);
if (input == null) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context)
.client
.changePassword(input.last, oldPassword: input.first),
);
if (success.error == null) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).passwordHasBeenChanged)));
}
}
void deleteAccountAction() async {
if (await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).warning,
message: L10n.of(context).deactivateAccountWarning,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) ==
OkCancelResult.cancel) {
return;
}
if (await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) ==
OkCancelResult.cancel) {
return;
}
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).pleaseEnterYourPassword,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
obscureText: true,
hintText: '******',
minLines: 1,
maxLines: 1,
)
],
);
if (input == null) return;
await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.deactivateAccount(
auth: AuthenticationPassword(
password: input.single,
user: Matrix.of(context).client.userID,
identifier: AuthenticationUserIdentifier(
user: Matrix.of(context).client.userID),
),
),
);
}
void setJitsiInstanceAction() async {
const prefix = 'https://';
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).editJitsiInstance,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
initialText: AppConfig.jitsiInstance.replaceFirst(prefix, ''),
prefixText: prefix,
),
],
);
if (input == null) return;
var jitsi = prefix + input.single;
if (!jitsi.endsWith('/')) {
jitsi += '/';
}
final matrix = Matrix.of(context);
await matrix.store.setItem(SettingKeys.jitsiInstance, jitsi);
AppConfig.jitsiInstance = jitsi;
}
void setDisplaynameAction() async {
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).editDisplayname,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
initialText: profile?.displayname ??
Matrix.of(context).client.userID.localpart,
)
],
);
if (input == null) return;
final matrix = Matrix.of(context);
final success = await showFutureLoadingDialog(
context: context,
future: () =>
matrix.client.setDisplayName(matrix.client.userID, input.single),
);
if (success.error == null) {
setState(() {
profileFuture = null;
profile = null;
});
}
}
void setAvatarAction() async {
MatrixFile file;
if (PlatformInfos.isMobile) {
final result = await ImagePicker().getImage(
source: ImageSource.gallery,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (result == null) return;
file = MatrixFile(
bytes: await result.readAsBytes(),
name: result.path,
);
} else {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
if (result == null) return;
file = MatrixFile(
bytes: result.toUint8List(),
name: result.fileName,
);
}
final matrix = Matrix.of(context);
final success = await showFutureLoadingDialog(
context: context,
future: () => matrix.client.setAvatar(file),
);
if (success.error == null) {
setState(() {
profileFuture = null;
profile = null;
});
}
}
Future<void> requestSSSSCache() async {
final handle = Matrix.of(context).client.encryption.ssss.open();
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).askSSSSCache,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: L10n.of(context).passphraseOrKey,
obscureText: true,
minLines: 1,
maxLines: 1,
)
],
);
if (input != null) {
final valid = await showFutureLoadingDialog(
context: context,
future: () async {
// make sure the loading spinner shows before we test the keys
await Future.delayed(Duration(milliseconds: 100));
var valid = false;
try {
await handle.unlock(recoveryKey: input.single);
valid = true;
} catch (e, s) {
SentryController.captureException(e, s);
}
return valid;
});
if (valid.result == true) {
await handle.maybeCacheAll();
await showOkAlertDialog(
context: context,
message: L10n.of(context).cachedKeys,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
);
setState(() {
crossSigningCachedFuture = null;
crossSigningCached = null;
megolmBackupCachedFuture = null;
megolmBackupCached = null;
});
} else {
await showOkAlertDialog(
context: context,
message: L10n.of(context).incorrectPassphraseOrKey,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
);
}
}
}
void setAppLockAction() async {
final currentLock =
await FlutterSecureStorage().read(key: SettingKeys.appLockKey);
if (currentLock?.isNotEmpty ?? false) {
await AppLock.of(context).showLockScreen();
}
final newLock = await showTextInputDialog(
context: context,
title: L10n.of(context).pleaseChooseAPasscode,
message: L10n.of(context).pleaseEnter4Digits,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
validator: (text) {
if (text.isEmpty || (text.length == 4 && int.tryParse(text) >= 0)) {
return null;
}
return L10n.of(context).pleaseEnter4Digits;
},
keyboardType: TextInputType.number,
obscureText: true,
maxLines: 1,
minLines: 1,
)
],
);
if (newLock != null) {
await FlutterSecureStorage()
.write(key: SettingKeys.appLockKey, value: newLock.single);
if (newLock.single.isEmpty) {
AppLock.of(context).disable();
} else {
AppLock.of(context).enable();
}
}
}
void bootstrapSettingsAction() async {
if (await Matrix.of(context).client.encryption.keyManager.isCached()) {
if (OkCancelResult.ok ==
await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).keysCached,
message: L10n.of(context).wipeChatBackup,
isDestructiveAction: true,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
)) {
await BootstrapDialog(
client: Matrix.of(context).client,
wipe: true,
).show(context);
}
return;
}
await BootstrapDialog(
client: Matrix.of(context).client,
).show(context);
}
void firstRunBootstrapAction() async {
await BootstrapDialog(
client: Matrix.of(context).client,
).show(context);
AdaptivePageLayout.of(context).popUntilIsFirst();
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
profileFuture ??= client.ownProfile.then((p) {
if (mounted) setState(() => profile = p);
return p;
});
if (client.encryption != null) {
crossSigningCachedFuture ??=
client.encryption?.crossSigning?.isCached()?.then((c) {
if (mounted) setState(() => crossSigningCached = c);
return c;
});
megolmBackupCachedFuture ??=
client.encryption?.keyManager?.isCached()?.then((c) {
if (mounted) setState(() => megolmBackupCached = c);
return c;
});
}
return SettingsUI(this);
}
}

View file

@ -0,0 +1,90 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'views/settings_3pid_ui.dart';
class Settings3Pid extends StatefulWidget {
static int sendAttempt = 0;
@override
Settings3PidController createState() => Settings3PidController();
}
class Settings3PidController extends State<Settings3Pid> {
void add3PidAction() async {
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).enterAnEmailAddress,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: L10n.of(context).enterAnEmailAddress,
keyboardType: TextInputType.emailAddress,
),
],
);
if (input == null) return;
final clientSecret = DateTime.now().millisecondsSinceEpoch.toString();
final response = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.requestEmailToken(
input.single,
clientSecret,
Settings3Pid.sendAttempt++,
),
);
if (response.error != null) return;
final ok = await showOkAlertDialog(
context: context,
title: L10n.of(context).weSentYouAnEmail,
message: L10n.of(context).pleaseClickOnLink,
okLabel: L10n.of(context).iHaveClickedOnLink,
useRootNavigator: false,
);
if (ok == null) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.uiaRequestBackground(
(auth) => Matrix.of(context).client.add3PID(
clientSecret,
response.result.sid,
auth: auth,
),
),
);
if (success.error != null) return;
setState(() => request = null);
}
Future<List<ThirdPartyIdentifier>> request;
void delete3Pid(ThirdPartyIdentifier identifier) async {
if (await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
) !=
OkCancelResult.ok) {
return;
}
final success = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.delete3pidFromAccount(
identifier.address,
identifier.medium,
));
if (success.error != null) return;
setState(() => request = null);
}
@override
Widget build(BuildContext context) => Settings3PidUI(this);
}

View file

@ -0,0 +1,300 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:image_picker/image_picker.dart';
import 'views/settings_emotes_ui.dart';
import '../widgets/matrix.dart';
class EmotesSettings extends StatefulWidget {
final Room room;
final String stateKey;
EmotesSettings({this.room, this.stateKey});
@override
EmotesSettingsController createState() => EmotesSettingsController();
}
class EmoteEntry {
String emote;
String mxc;
EmoteEntry({this.emote, this.mxc});
String get emoteClean => emote.substring(1, emote.length - 1);
}
class EmotesSettingsController extends State<EmotesSettings> {
List<EmoteEntry> emotes;
bool showSave = false;
TextEditingController newEmoteController = TextEditingController();
TextEditingController newMxcController = TextEditingController();
Future<void> _save(BuildContext context) async {
if (readonly) {
return;
}
final client = Matrix.of(context).client;
// be sure to preserve any data not in "short"
Map<String, dynamic> content;
if (widget.room != null) {
content = widget.room
.getState('im.ponies.room_emotes', widget.stateKey ?? '')
?.content ??
<String, dynamic>{};
} else {
content = client.accountData['im.ponies.user_emotes']?.content ??
<String, dynamic>{};
}
if (!(content['emoticons'] is Map)) {
content['emoticons'] = <String, dynamic>{};
}
// add / update changed emotes
final allowedShortcodes = <String>{};
for (final emote in emotes) {
allowedShortcodes.add(emote.emote);
if (!(content['emoticons'][emote.emote] is Map)) {
content['emoticons'][emote.emote] = <String, dynamic>{};
}
content['emoticons'][emote.emote]['url'] = emote.mxc;
}
// remove emotes no more needed
// we make the iterator .toList() here so that we don't get into trouble modifying the very
// thing we are iterating over
for (final shortcode in content['emoticons'].keys.toList()) {
if (!allowedShortcodes.contains(shortcode)) {
content['emoticons'].remove(shortcode);
}
}
// remove the old "short" key
content.remove('short');
if (widget.room != null) {
await showFutureLoadingDialog(
context: context,
future: () => client.setRoomStateWithKey(widget.room.id,
'im.ponies.room_emotes', content, widget.stateKey ?? ''),
);
} else {
await showFutureLoadingDialog(
context: context,
future: () => client.setAccountData(
client.userID, 'im.ponies.user_emotes', content),
);
}
}
Future<void> setIsGloballyActive(bool active) async {
if (widget.room == null) {
return;
}
final client = Matrix.of(context).client;
final content = client.accountData['im.ponies.emote_rooms']?.content ??
<String, dynamic>{};
if (active) {
if (!(content['rooms'] is Map)) {
content['rooms'] = <String, dynamic>{};
}
if (!(content['rooms'][widget.room.id] is Map)) {
content['rooms'][widget.room.id] = <String, dynamic>{};
}
if (!(content['rooms'][widget.room.id][widget.stateKey ?? ''] is Map)) {
content['rooms'][widget.room.id]
[widget.stateKey ?? ''] = <String, dynamic>{};
}
} else if (content['rooms'] is Map &&
content['rooms'][widget.room.id] is Map) {
content['rooms'][widget.room.id].remove(widget.stateKey ?? '');
}
// and save
await showFutureLoadingDialog(
context: context,
future: () => client.setAccountData(
client.userID, 'im.ponies.emote_rooms', content),
);
setState(() => null);
}
void removeEmoteAction(EmoteEntry emote) => setState(() {
emotes.removeWhere((e) => e.emote == emote.emote);
showSave = true;
});
void submitEmoteAction(
String s,
EmoteEntry emote,
TextEditingController controller,
) {
final emoteCode = ':$s:';
if (emotes.indexWhere((e) => e.emote == emoteCode && e.mxc != emote.mxc) !=
-1) {
controller.text = emote.emoteClean;
showOkAlertDialog(
context: context,
message: L10n.of(context).emoteExists,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
);
return;
}
if (!RegExp(r'^:[-\w]+:$').hasMatch(emoteCode)) {
controller.text = emote.emoteClean;
showOkAlertDialog(
context: context,
message: L10n.of(context).emoteInvalid,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
);
return;
}
setState(() {
emote.emote = emoteCode;
showSave = true;
});
}
bool isGloballyActive(Client client) =>
widget.room != null &&
client.accountData['im.ponies.emote_rooms']?.content is Map &&
client.accountData['im.ponies.emote_rooms'].content['rooms'] is Map &&
client.accountData['im.ponies.emote_rooms'].content['rooms']
[widget.room.id] is Map &&
client.accountData['im.ponies.emote_rooms'].content['rooms']
[widget.room.id][widget.stateKey ?? ''] is Map;
bool get readonly => widget.room == null
? false
: !(widget.room.canSendEvent('im.ponies.room_emotes'));
void saveAction() async {
await _save(context);
setState(() {
showSave = false;
});
}
void addEmoteAction() async {
if (newEmoteController.text == null ||
newEmoteController.text.isEmpty ||
newMxcController.text == null ||
newMxcController.text.isEmpty) {
await showOkAlertDialog(
context: context,
message: L10n.of(context).emoteWarnNeedToPick,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
);
return;
}
final emoteCode = ':${newEmoteController.text}:';
final mxc = newMxcController.text;
if (emotes.indexWhere((e) => e.emote == emoteCode && e.mxc != mxc) != -1) {
await showOkAlertDialog(
context: context,
message: L10n.of(context).emoteExists,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
);
return;
}
if (!RegExp(r'^:[-\w]+:$').hasMatch(emoteCode)) {
await showOkAlertDialog(
context: context,
message: L10n.of(context).emoteInvalid,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
);
return;
}
emotes.add(EmoteEntry(emote: emoteCode, mxc: mxc));
await _save(context);
setState(() {
newEmoteController.text = '';
newMxcController.text = '';
showSave = false;
});
}
void emoteImagePickerAction(TextEditingController controller) async {
if (kIsWeb) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).notSupportedInWeb)));
return;
}
MatrixFile file;
if (PlatformInfos.isMobile) {
final result = await ImagePicker().getImage(
source: ImageSource.gallery,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (result == null) return;
file = MatrixFile(
bytes: await result.readAsBytes(),
name: result.path,
);
} else {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
if (result == null) return;
file = MatrixFile(
bytes: result.toUint8List(),
name: result.fileName,
);
}
final uploadResp = await showFutureLoadingDialog(
context: context,
future: () =>
Matrix.of(context).client.uploadContent(file.bytes, file.name),
);
if (uploadResp.error == null) {
setState(() {
controller.text = uploadResp.result;
});
}
}
@override
Widget build(BuildContext context) {
if (emotes == null) {
emotes = <EmoteEntry>[];
Map<String, dynamic> emoteSource;
if (widget.room != null) {
emoteSource = widget.room
.getState('im.ponies.room_emotes', widget.stateKey ?? '')
?.content;
} else {
emoteSource = Matrix.of(context)
.client
.accountData['im.ponies.user_emotes']
?.content;
}
if (emoteSource != null) {
if (emoteSource['emoticons'] is Map) {
emoteSource['emoticons'].forEach((key, value) {
if (key is String &&
value is Map &&
value['url'] is String &&
value['url'].startsWith('mxc://')) {
emotes.add(EmoteEntry(emote: key, mxc: value['url']));
}
});
} else if (emoteSource['short'] is Map) {
emoteSource['short'].forEach((key, value) {
if (key is String &&
value is String &&
value.startsWith('mxc://')) {
emotes.add(EmoteEntry(emote: key, mxc: value));
}
});
}
}
}
return EmotesSettingsUI(this);
}
}

View file

@ -0,0 +1,40 @@
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'views/settings_ignore_list_ui.dart';
import '../widgets/matrix.dart';
class SettingsIgnoreList extends StatefulWidget {
final String initialUserId;
SettingsIgnoreList({Key key, this.initialUserId}) : super(key: key);
@override
SettingsIgnoreListController createState() => SettingsIgnoreListController();
}
class SettingsIgnoreListController extends State<SettingsIgnoreList> {
final TextEditingController controller = TextEditingController();
@override
void initState() {
super.initState();
if (widget.initialUserId != null) {
controller.text = widget.initialUserId.replaceAll('@', '');
}
}
void ignoreUser(BuildContext context) {
if (controller.text.isEmpty) return;
final userId = '@${controller.text}';
showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.ignoreUser(userId),
);
controller.clear();
}
@override
Widget build(BuildContext context) => SettingsIgnoreListUI(this);
}

View file

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'views/settings_multiple_emotes_ui.dart';
class MultipleEmotesSettings extends StatefulWidget {
final String roomId;
MultipleEmotesSettings(this.roomId, {Key key}) : super(key: key);
@override
MultipleEmotesSettingsController createState() =>
MultipleEmotesSettingsController();
}
class MultipleEmotesSettingsController extends State<MultipleEmotesSettings> {
@override
Widget build(BuildContext context) => MultipleEmotesSettingsUI(this);
}

View file

@ -0,0 +1,112 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:open_noti_settings/open_noti_settings.dart';
import 'views/settings_notifications_ui.dart';
import '../widgets/matrix.dart';
class NotificationSettingsItem {
final PushRuleKind type;
final String key;
final String Function(BuildContext) title;
const NotificationSettingsItem(this.type, this.key, this.title);
static List<NotificationSettingsItem> items = [
NotificationSettingsItem(
PushRuleKind.underride,
'.m.rule.room_one_to_one',
(c) => L10n.of(c).directChats,
),
NotificationSettingsItem(
PushRuleKind.override,
'.m.rule.contains_display_name',
(c) => L10n.of(c).containsDisplayName,
),
NotificationSettingsItem(
PushRuleKind.content,
'.m.rule.contains_user_name',
(c) => L10n.of(c).containsUserName,
),
NotificationSettingsItem(
PushRuleKind.override,
'.m.rule.invite_for_me',
(c) => L10n.of(c).inviteForMe,
),
NotificationSettingsItem(
PushRuleKind.override,
'.m.rule.member_event',
(c) => L10n.of(c).memberChanges,
),
NotificationSettingsItem(
PushRuleKind.override,
'.m.rule.suppress_notices',
(c) => L10n.of(c).botMessages,
),
];
}
class SettingsNotifications extends StatefulWidget {
@override
SettingsNotificationsController createState() =>
SettingsNotificationsController();
}
class SettingsNotificationsController extends State<SettingsNotifications> {
void openAndroidNotificationSettingsAction() async {
await NotificationSetting.configureChannel(
NotificationDetails(
android: AndroidNotificationDetails(
AppConfig.pushNotificationsChannelId,
AppConfig.pushNotificationsChannelName,
AppConfig.pushNotificationsChannelDescription,
),
),
);
return NotificationSetting.open();
}
bool getNotificationSetting(NotificationSettingsItem item) {
final pushRules = Matrix.of(context).client.globalPushRules;
switch (item.type) {
case PushRuleKind.content:
return pushRules.content
?.singleWhere((r) => r.ruleId == item.key, orElse: () => null)
?.enabled;
case PushRuleKind.override:
return pushRules.override
?.singleWhere((r) => r.ruleId == item.key, orElse: () => null)
?.enabled;
case PushRuleKind.room:
return pushRules.room
?.singleWhere((r) => r.ruleId == item.key, orElse: () => null)
?.enabled;
case PushRuleKind.sender:
return pushRules.sender
?.singleWhere((r) => r.ruleId == item.key, orElse: () => null)
?.enabled;
case PushRuleKind.underride:
return pushRules.underride
?.singleWhere((r) => r.ruleId == item.key, orElse: () => null)
?.enabled;
}
return false;
}
void setNotificationSetting(NotificationSettingsItem item, bool enabled) {
showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.setPushRuleEnabled(
'global',
item.type,
item.key,
enabled,
),
);
}
@override
Widget build(BuildContext context) => SettingsNotificationsUI(this);
}

View file

@ -0,0 +1,61 @@
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'views/settings_style_ui.dart';
import '../widgets/matrix.dart';
class SettingsStyle extends StatefulWidget {
@override
SettingsStyleController createState() => SettingsStyleController();
}
class SettingsStyleController extends State<SettingsStyle> {
void setWallpaperAction() async {
final wallpaper = await ImagePicker().getImage(source: ImageSource.gallery);
if (wallpaper == null) return;
Matrix.of(context).wallpaper = File(wallpaper.path);
await Matrix.of(context)
.store
.setItem(SettingKeys.wallpaper, wallpaper.path);
setState(() => null);
}
void deleteWallpaperAction() async {
Matrix.of(context).wallpaper = null;
await Matrix.of(context).store.deleteItem(SettingKeys.wallpaper);
setState(() => null);
}
AdaptiveThemeMode currentTheme;
void switchTheme(AdaptiveThemeMode newTheme) {
switch (newTheme) {
case AdaptiveThemeMode.light:
AdaptiveTheme.of(context).setLight();
break;
case AdaptiveThemeMode.dark:
AdaptiveTheme.of(context).setDark();
break;
case AdaptiveThemeMode.system:
AdaptiveTheme.of(context).setSystem();
break;
}
setState(() => currentTheme = newTheme);
}
void changeFontSizeFactor(double d) {
setState(() => AppConfig.fontSizeFactor = d);
Matrix.of(context).store.setItem(
SettingKeys.fontSizeFactor,
AppConfig.fontSizeFactor.toString(),
);
}
@override
Widget build(BuildContext context) => SettingsStyleUI(this);
}

71
lib/pages/sign_up.dart Normal file
View file

@ -0,0 +1,71 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/pages/views/sign_up_ui.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class SignUp extends StatefulWidget {
@override
SignUpController createState() => SignUpController();
}
class SignUpController extends State<SignUp> {
final TextEditingController usernameController = TextEditingController();
String usernameError;
bool loading = false;
MatrixFile avatar;
void setAvatarAction() async {
final file =
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
if (file != null) {
setState(
() => avatar = MatrixFile(
bytes: file.toUint8List(),
name: file.fileName,
),
);
}
}
void resetAvatarAction() => setState(() => avatar = null);
void signUpAction([_]) async {
final matrix = Matrix.of(context);
if (usernameController.text.isEmpty) {
setState(() => usernameError = L10n.of(context).pleaseChooseAUsername);
} else {
setState(() => usernameError = null);
}
if (usernameController.text.isEmpty) {
return;
}
setState(() => loading = true);
final preferredUsername =
usernameController.text.toLowerCase().trim().replaceAll(' ', '-');
try {
await matrix.client.checkUsernameAvailability(preferredUsername);
} on MatrixException catch (exception) {
setState(() => usernameError = exception.errorMessage);
return setState(() => loading = false);
} catch (exception) {
setState(() => usernameError = exception.toString());
return setState(() => loading = false);
}
setState(() => loading = false);
await AdaptivePageLayout.of(context).pushNamed(
'/signup/password/${Uri.encodeComponent(preferredUsername)}/${Uri.encodeComponent(usernameController.text)}',
arguments: avatar,
);
}
@override
Widget build(BuildContext context) => SignUpUI(this);
}

View file

@ -0,0 +1,115 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:email_validator/email_validator.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/get_client_secret.dart';
import 'package:fluffychat/pages/views/sign_up_password_ui.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../utils/platform_infos.dart';
class SignUpPassword extends StatefulWidget {
final MatrixFile avatar;
final String username;
final String displayname;
const SignUpPassword(this.username, {this.avatar, this.displayname});
@override
SignUpPasswordController createState() => SignUpPasswordController();
}
class SignUpPasswordController extends State<SignUpPassword> {
final TextEditingController passwordController = TextEditingController();
final TextEditingController emailController = TextEditingController();
String passwordError;
String emailError;
bool loading = false;
bool showPassword = true;
void toggleShowPassword() => setState(() => showPassword = !showPassword);
void signUpAction() async {
final matrix = Matrix.of(context);
if (passwordController.text.isEmpty) {
setState(() => passwordError = L10n.of(context).pleaseEnterYourPassword);
} else {
setState(() => passwordError = emailError = null);
}
if (passwordController.text.isEmpty) {
return;
}
try {
setState(() => loading = true);
if (emailController.text.isNotEmpty) {
emailController.text = emailController.text.trim();
if (!EmailValidator.validate(emailController.text)) {
setState(() => emailError = L10n.of(context).invalidEmail);
return;
}
matrix.currentClientSecret = getClientSecret(30);
Logs().d('Request email token');
matrix.currentThreepidCreds = await matrix.client.requestEmailToken(
emailController.text,
matrix.currentClientSecret,
1,
);
if (OkCancelResult.ok !=
await showOkCancelAlertDialog(
context: context,
message: L10n.of(context).weSentYouAnEmail,
okLabel: L10n.of(context).confirm,
cancelLabel: L10n.of(context).cancel,
)) {
matrix.currentClientSecret = matrix.currentThreepidCreds = null;
setState(() => loading = false);
return;
}
}
final waitForLogin = matrix.client.onLoginStateChanged.stream.first;
await matrix.client.uiaRequestBackground((auth) => matrix.client.register(
username: widget.username,
password: passwordController.text,
initialDeviceDisplayName: PlatformInfos.clientName,
auth: auth,
));
if (matrix.currentClientSecret != null &&
matrix.currentThreepidCreds != null) {
Logs().d('Add third party identifier');
await matrix.client.add3PID(
matrix.currentClientSecret,
matrix.currentThreepidCreds.sid,
);
}
await waitForLogin;
} catch (exception) {
setState(() => emailError = exception.toString());
return setState(() => loading = false);
}
await matrix.client.onLoginStateChanged.stream
.firstWhere((l) => l == LoginState.logged);
// tchncs.de
try {
await matrix.client
.setDisplayName(matrix.client.userID, widget.displayname);
} catch (exception) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).couldNotSetDisplayname)));
}
if (widget.avatar != null) {
try {
await matrix.client.setAvatar(widget.avatar);
} catch (exception) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).couldNotSetAvatar)));
}
}
if (mounted) setState(() => loading = false);
}
@override
Widget build(BuildContext context) => SignUpPasswordUI(this);
}

View file

@ -0,0 +1,95 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/permission_slider_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'views/user_bottom_sheet_ui.dart';
class UserBottomSheet extends StatefulWidget {
final User user;
final Function onMention;
final BuildContext outerContext;
const UserBottomSheet({
Key key,
@required this.user,
@required this.outerContext,
this.onMention,
}) : super(key: key);
@override
UserBottomSheetController createState() => UserBottomSheetController();
}
class UserBottomSheetController extends State<UserBottomSheet> {
void participantAction(String action) async {
final Function _askConfirmation =
() async => (await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).no,
) ==
OkCancelResult.ok);
switch (action) {
case 'mention':
Navigator.of(context, rootNavigator: false).pop();
widget.onMention();
break;
case 'ban':
if (await _askConfirmation()) {
await showFutureLoadingDialog(
context: context,
future: () => widget.user.ban(),
);
Navigator.of(context, rootNavigator: false).pop();
}
break;
case 'unban':
if (await _askConfirmation()) {
await showFutureLoadingDialog(
context: context,
future: () => widget.user.unban(),
);
Navigator.of(context, rootNavigator: false).pop();
}
break;
case 'kick':
if (await _askConfirmation()) {
await showFutureLoadingDialog(
context: context,
future: () => widget.user.kick(),
);
Navigator.of(context, rootNavigator: false).pop();
}
break;
case 'permission':
final newPermission = await PermissionSliderDialog(
initialPermission: widget.user.powerLevel)
.show(context);
if (newPermission != null) {
if (newPermission == 100 && await _askConfirmation() == false) break;
await showFutureLoadingDialog(
context: context,
future: () => widget.user.setPower(newPermission),
);
Navigator.of(context, rootNavigator: false).pop();
}
break;
case 'message':
final roomId = await widget.user.startDirectChat();
await AdaptivePageLayout.of(widget.outerContext)
.pushNamedAndRemoveUntilIsFirst('/rooms/$roomId');
Navigator.of(context, rootNavigator: false).pop();
break;
}
}
@override
Widget build(BuildContext context) => UserBottomSheetUI(this);
}

View file

@ -0,0 +1,59 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/archive.dart';
import 'package:fluffychat/widgets/list_items/chat_list_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ArchiveView extends StatelessWidget {
final ArchiveController controller;
const ArchiveView(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Room>>(
future: controller.getArchive(context),
builder: (BuildContext context, snapshot) => Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).archive),
actions: [
if (snapshot.hasData &&
controller.archive != null &&
controller.archive.isNotEmpty)
TextButton(
onPressed: controller.forgetAllAction,
child: Text(L10n.of(context).clearArchive),
)
],
),
body: Builder(
builder: (BuildContext context) {
if (snapshot.hasError) {
return Center(
child: Text(
L10n.of(context).oopsSomethingWentWrong,
textAlign: TextAlign.center,
));
}
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
} else {
controller.archive = snapshot.data;
if (controller.archive.isEmpty) {
return Center(child: Icon(Icons.archive_outlined, size: 80));
}
return ListView.builder(
itemCount: controller.archive.length,
itemBuilder: (BuildContext context, int i) => ChatListItem(
controller.archive[i],
onForget: controller.forgetAction,
),
);
}
},
),
),
);
}
}

View file

@ -0,0 +1,367 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_details.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/chat_settings_popup_menu.dart';
import 'package:fluffychat/widgets/content_banner.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/list_items/participant_list_item.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix_link_text/link_text.dart';
import '../../utils/url_launcher.dart';
class ChatDetailsUI extends StatelessWidget {
final ChatDetailsController controller;
const ChatDetailsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final room =
Matrix.of(context).client.getRoomById(controller.widget.roomId);
if (room == null) {
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).oopsSomethingWentWrong),
),
body: Center(
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
);
}
controller.members.removeWhere((u) => u.membership == Membership.leave);
final actualMembersCount =
room.mInvitedMemberCount + room.mJoinedMemberCount;
final canRequestMoreMembers =
controller.members.length < actualMembersCount;
return StreamBuilder(
stream: room.onUpdate.stream,
builder: (context, snapshot) {
return Scaffold(
body: NestedScrollView(
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) => <Widget>[
SliverAppBar(
elevation: Theme.of(context).appBarTheme.elevation,
leading: BackButton(),
expandedHeight: 300.0,
floating: true,
pinned: true,
actions: <Widget>[
if (room.canonicalAlias?.isNotEmpty ?? false)
IconButton(
tooltip: L10n.of(context).share,
icon: Icon(Icons.share_outlined),
onPressed: () => FluffyShare.share(
AppConfig.inviteLinkPrefix + room.canonicalAlias,
context),
),
ChatSettingsPopupMenu(room, false)
],
title: Text(
room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context))),
style: TextStyle(
color: Theme.of(context)
.appBarTheme
.textTheme
.headline6
.color)),
backgroundColor: Theme.of(context).appBarTheme.color,
flexibleSpace: FlexibleSpaceBar(
background: ContentBanner(room.avatar,
onEdit: room.canSendEvent('m.room.avatar')
? controller.setAvatarAction
: null),
),
),
],
body: MaxWidthBody(
child: ListView.builder(
itemCount: controller.members.length +
1 +
(canRequestMoreMembers ? 1 : 0),
itemBuilder: (BuildContext context, int i) => i == 0
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
ListTile(
leading: room.canSendEvent('m.room.topic')
? CircleAvatar(
backgroundColor: Theme.of(context)
.scaffoldBackgroundColor,
foregroundColor: Colors.grey,
radius: Avatar.defaultSize / 2,
child: Icon(Icons.edit_outlined),
)
: null,
title: Text(
'${L10n.of(context).groupDescription}:',
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold)),
subtitle: LinkText(
text: room.topic?.isEmpty ?? true
? L10n.of(context).addGroupDescription
: room.topic,
linkStyle: TextStyle(color: Colors.blueAccent),
textStyle: TextStyle(
fontSize: 14,
color: Theme.of(context)
.textTheme
.bodyText2
.color,
),
onLinkTap: (url) =>
UrlLauncher(context, url).launchUrl(),
),
onTap: room.canSendEvent('m.room.topic')
? controller.setTopicAction
: null,
),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).settings,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
if (room.canSendEvent('m.room.name'))
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.people_outlined),
),
title: Text(
L10n.of(context).changeTheNameOfTheGroup),
subtitle: Text(room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)))),
onTap: controller.setDisplaynameAction,
),
if (room.joinRules == JoinRules.public)
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.link_outlined),
),
onTap: controller.editAliases,
title: Text(L10n.of(context).editRoomAliases),
subtitle: Text(
(room.canonicalAlias?.isNotEmpty ?? false)
? room.canonicalAlias
: L10n.of(context).none),
),
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.insert_emoticon_outlined),
),
title: Text(L10n.of(context).emoteSettings),
subtitle: Text(L10n.of(context).setCustomEmotes),
onTap: controller.goToEmoteSettings,
),
PopupMenuButton(
onSelected: controller.setJoinRulesAction,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<JoinRules>>[
if (room.canChangeJoinRules)
PopupMenuItem<JoinRules>(
value: JoinRules.public,
child: Text(JoinRules.public
.getLocalizedString(
MatrixLocals(L10n.of(context)))),
),
if (room.canChangeJoinRules)
PopupMenuItem<JoinRules>(
value: JoinRules.invite,
child: Text(JoinRules.invite
.getLocalizedString(
MatrixLocals(L10n.of(context)))),
),
],
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context)
.scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.public_outlined)),
title: Text(L10n.of(context)
.whoIsAllowedToJoinThisGroup),
subtitle: Text(
room.joinRules.getLocalizedString(
MatrixLocals(L10n.of(context))),
),
),
),
PopupMenuButton(
onSelected: controller.setHistoryVisibilityAction,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<HistoryVisibility>>[
if (room.canChangeHistoryVisibility)
PopupMenuItem<HistoryVisibility>(
value: HistoryVisibility.invited,
child: Text(HistoryVisibility.invited
.getLocalizedString(
MatrixLocals(L10n.of(context)))),
),
if (room.canChangeHistoryVisibility)
PopupMenuItem<HistoryVisibility>(
value: HistoryVisibility.joined,
child: Text(HistoryVisibility.joined
.getLocalizedString(
MatrixLocals(L10n.of(context)))),
),
if (room.canChangeHistoryVisibility)
PopupMenuItem<HistoryVisibility>(
value: HistoryVisibility.shared,
child: Text(HistoryVisibility.shared
.getLocalizedString(
MatrixLocals(L10n.of(context)))),
),
if (room.canChangeHistoryVisibility)
PopupMenuItem<HistoryVisibility>(
value: HistoryVisibility.worldReadable,
child: Text(HistoryVisibility.worldReadable
.getLocalizedString(
MatrixLocals(L10n.of(context)))),
),
],
child: ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.visibility_outlined),
),
title: Text(L10n.of(context)
.visibilityOfTheChatHistory),
subtitle: Text(
room.historyVisibility.getLocalizedString(
MatrixLocals(L10n.of(context))),
),
),
),
if (room.joinRules == JoinRules.public)
PopupMenuButton(
onSelected: controller.setGuestAccessAction,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<GuestAccess>>[
if (room.canChangeGuestAccess)
PopupMenuItem<GuestAccess>(
value: GuestAccess.canJoin,
child: Text(
GuestAccess.canJoin.getLocalizedString(
MatrixLocals(L10n.of(context))),
),
),
if (room.canChangeGuestAccess)
PopupMenuItem<GuestAccess>(
value: GuestAccess.forbidden,
child: Text(
GuestAccess.forbidden
.getLocalizedString(
MatrixLocals(L10n.of(context))),
),
),
],
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context)
.scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.info_outline),
),
title: Text(
L10n.of(context).areGuestsAllowedToJoin),
subtitle: Text(
room.guestAccess.getLocalizedString(
MatrixLocals(L10n.of(context))),
),
),
),
ListTile(
title: Text(L10n.of(context).editChatPermissions),
subtitle: Text(
L10n.of(context).whoCanPerformWhichAction),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.edit_attributes_outlined),
),
onTap: () => AdaptivePageLayout.of(context)
.pushNamed('/rooms/${room.id}/permissions'),
),
Divider(thickness: 1),
ListTile(
title: Text(
actualMembersCount > 1
? L10n.of(context).countParticipants(
actualMembersCount.toString())
: L10n.of(context).emptyChat,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
room.canInvite
? ListTile(
title: Text(L10n.of(context).inviteContact),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).primaryColor,
foregroundColor: Colors.white,
radius: Avatar.defaultSize / 2,
child: Icon(Icons.add_outlined),
),
onTap: () => AdaptivePageLayout.of(context)
.pushNamed('/rooms/${room.id}/invite'),
)
: Container(),
],
)
: i < controller.members.length + 1
? ParticipantListItem(controller.members[i - 1])
: ListTile(
title: Text(L10n.of(context)
.loadCountMoreParticipants(
(actualMembersCount -
controller.members.length)
.toString())),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
child: Icon(
Icons.refresh,
color: Colors.grey,
),
),
onTap: controller.requestMoreMembersAction,
),
),
),
),
);
});
}
}

View file

@ -0,0 +1,199 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/chat_encryption_settings.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/device_extension.dart';
class ChatEncryptionSettingsUI extends StatelessWidget {
final ChatEncryptionSettingsController controller;
const ChatEncryptionSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(controller.widget.id);
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).tapOnDeviceToVerify),
bottom: PreferredSize(
preferredSize: Size.fromHeight(56),
child: ListTile(
title: Text(L10n.of(context).deviceVerifyDescription),
leading: CircleAvatar(
backgroundColor: Theme.of(context).secondaryHeaderColor,
foregroundColor: Theme.of(context).accentColor,
child: Icon(Icons.lock),
),
),
),
),
body: MaxWidthBody(
withScrolling: true,
child: StreamBuilder(
stream: room.onUpdate.stream,
builder: (context, snapshot) {
return FutureBuilder<List<DeviceKeys>>(
future: room.getUserDeviceKeys(),
builder: (BuildContext context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(L10n.of(context).oopsSomethingWentWrong +
': ' +
snapshot.error.toString()),
);
}
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
}
final deviceKeys = snapshot.data;
return ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: deviceKeys.length,
itemBuilder: (BuildContext context, int i) => Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (i == 0 ||
deviceKeys[i].userId !=
deviceKeys[i - 1].userId) ...{
Divider(height: 1, thickness: 1),
PopupMenuButton(
onSelected: (action) => controller.onSelected(
context, action, deviceKeys[i]),
itemBuilder: (c) {
final items = <PopupMenuEntry<String>>[];
if (room
.client
.userDeviceKeys[deviceKeys[i].userId]
.verified ==
UserVerifiedStatus.unknown) {
items.add(PopupMenuItem(
value: 'verify_user',
child: Text(L10n.of(context).verifyUser),
));
}
return items;
},
child: ListTile(
leading: Avatar(
room
.getUserByMXIDSync(deviceKeys[i].userId)
.avatarUrl,
room
.getUserByMXIDSync(deviceKeys[i].userId)
.calcDisplayname(),
),
title: Row(
children: [
Text(
room
.getUserByMXIDSync(deviceKeys[i].userId)
.calcDisplayname(),
),
Spacer(),
Text(
deviceKeys[i].userId,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.textTheme
.bodyText1
.color
.withAlpha(150),
),
),
],
),
),
),
},
PopupMenuButton(
onSelected: (action) => controller.onSelected(
context, action, deviceKeys[i]),
itemBuilder: (c) {
final items = <PopupMenuEntry<String>>[];
if (deviceKeys[i].blocked ||
!deviceKeys[i].verified) {
items.add(PopupMenuItem(
value:
deviceKeys[i].userId == room.client.userID
? 'verify'
: 'verify_user',
child: Text(L10n.of(context).verifyStart),
));
}
if (deviceKeys[i].blocked) {
items.add(PopupMenuItem(
value: 'unblock',
child: Text(L10n.of(context).unblockDevice),
));
}
if (!deviceKeys[i].blocked) {
items.add(PopupMenuItem(
value: 'block',
child: Text(L10n.of(context).blockDevice),
));
}
return items;
},
child: ListTile(
leading: CircleAvatar(
foregroundColor:
Theme.of(context).textTheme.bodyText1.color,
backgroundColor:
Theme.of(context).secondaryHeaderColor,
child: Icon(deviceKeys[i].icon),
),
title: Text(
deviceKeys[i].displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
children: [
Text(
deviceKeys[i].deviceId,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.textTheme
.bodyText1
.color
.withAlpha(150),
),
),
Spacer(),
Text(
deviceKeys[i].blocked
? L10n.of(context).blocked
: deviceKeys[i].verified
? L10n.of(context).verified
: L10n.of(context).unknownDevice,
style: TextStyle(
fontSize: 14,
color: deviceKeys[i].blocked
? Colors.red
: deviceKeys[i].verified
? Colors.green
: Colors.orange,
),
),
],
),
),
),
],
),
);
},
);
}),
),
);
}
}

View file

@ -0,0 +1,242 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/chat_list.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
import 'package:fluffychat/widgets/list_items/chat_list_item.dart';
import 'package:flutter/cupertino.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../widgets/matrix.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ChatListUI extends StatelessWidget {
final ChatListController controller;
const ChatListUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return StreamBuilder<Object>(
stream: Matrix.of(context).onShareContentChanged.stream,
builder: (_, __) {
final selectMode = Matrix.of(context).shareContent != null
? SelectMode.share
: controller.selectedRoomIds.isEmpty
? SelectMode.normal
: SelectMode.select;
return Scaffold(
appBar: AppBar(
elevation:
AdaptivePageLayout.of(context).columnMode(context) ? 1 : null,
leading: selectMode == SelectMode.normal
? null
: IconButton(
tooltip: L10n.of(context).cancel,
icon: Icon(Icons.close_outlined),
onPressed: controller.cancelAction,
),
centerTitle: false,
actions: selectMode == SelectMode.share
? null
: selectMode == SelectMode.select
? [
if (controller.selectedRoomIds.length == 1)
IconButton(
tooltip: L10n.of(context).toggleUnread,
icon: Icon(Matrix.of(context)
.client
.getRoomById(
controller.selectedRoomIds.single)
.isUnread
? Icons.mark_chat_read_outlined
: Icons.mark_chat_unread_outlined),
onPressed: controller.toggleUnread,
),
if (controller.selectedRoomIds.length == 1)
IconButton(
tooltip: L10n.of(context).toggleFavorite,
icon: Icon(Icons.push_pin_outlined),
onPressed: controller.toggleFavouriteRoom,
),
if (controller.selectedRoomIds.length == 1)
IconButton(
icon: Icon(Matrix.of(context)
.client
.getRoomById(
controller.selectedRoomIds.single)
.pushRuleState ==
PushRuleState.notify
? Icons.notifications_off_outlined
: Icons.notifications_outlined),
tooltip: L10n.of(context).toggleMuted,
onPressed: controller.toggleMuted,
),
IconButton(
icon: Icon(Icons.archive_outlined),
tooltip: L10n.of(context).archive,
onPressed: controller.archiveAction,
),
]
: [
IconButton(
icon: Icon(Icons.search_outlined),
tooltip: L10n.of(context).search,
onPressed: () => AdaptivePageLayout.of(context)
.pushNamed('/search'),
),
PopupMenuButton<PopupMenuAction>(
onSelected: controller.onPopupMenuSelect,
itemBuilder: (_) => [
PopupMenuItem(
value: PopupMenuAction.setStatus,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_outlined),
SizedBox(width: 12),
Text(L10n.of(context).setStatus),
],
),
),
PopupMenuItem(
value: PopupMenuAction.newGroup,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.group_add_outlined),
SizedBox(width: 12),
Text(L10n.of(context).createNewGroup),
],
),
),
PopupMenuItem(
value: PopupMenuAction.invite,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.share_outlined),
SizedBox(width: 12),
Text(L10n.of(context).inviteContact),
],
),
),
PopupMenuItem(
value: PopupMenuAction.archive,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.archive_outlined),
SizedBox(width: 12),
Text(L10n.of(context).archive),
],
),
),
PopupMenuItem(
value: PopupMenuAction.settings,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_outlined),
SizedBox(width: 12),
Text(L10n.of(context).settings),
],
),
),
],
),
],
title: Text(selectMode == SelectMode.share
? L10n.of(context).share
: selectMode == SelectMode.select
? L10n.of(context).numberSelected(
controller.selectedRoomIds.length.toString())
: AppConfig.applicationName),
),
body: Column(children: [
ConnectionStatusHeader(),
Expanded(
child: StreamBuilder(
stream: Matrix.of(context)
.client
.onSync
.stream
.where((s) => s.hasRoomUpdate),
builder: (context, snapshot) {
return FutureBuilder<void>(
future: controller.waitForFirstSync(),
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
final rooms = List<Room>.from(
Matrix.of(context).client.rooms);
rooms.removeWhere((room) => room.lastEvent == null);
if (rooms.isEmpty) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.maps_ugc_outlined,
size: 80,
color: Colors.grey,
),
Center(
child: Text(
L10n.of(context).startYourFirstChat,
textAlign: TextAlign.start,
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
),
],
);
}
final totalCount = rooms.length;
return ListView.builder(
itemCount: totalCount,
itemBuilder: (BuildContext context, int i) =>
ChatListItem(
rooms[i],
selected: controller.selectedRoomIds
.contains(rooms[i].id),
onTap: selectMode == SelectMode.select
? () =>
controller.toggleSelection(rooms[i].id)
: null,
onLongPress: () =>
controller.toggleSelection(rooms[i].id),
activeChat:
controller.widget.activeChat == rooms[i].id,
),
);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
},
);
}),
),
]),
floatingActionButton: selectMode == SelectMode.normal
? FloatingActionButton(
onPressed: () => AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/newprivatechat'),
child: Icon(CupertinoIcons.chat_bubble),
)
: null,
);
});
}
}
enum ChatListPopupMenuItemActions {
createGroup,
discover,
setStatus,
inviteContact,
settings,
}

View file

@ -0,0 +1,124 @@
import 'package:fluffychat/pages/chat_permissions_settings.dart';
import 'package:fluffychat/widgets/list_items/permission_list_tile.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:famedlysdk/famedlysdk.dart';
class ChatPermissionsSettingsUI extends StatelessWidget {
final ChatPermissionsSettingsController controller;
const ChatPermissionsSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).editChatPermissions),
),
body: MaxWidthBody(
withScrolling: true,
child: StreamBuilder(
stream: controller.onChanged,
builder: (context, _) {
final room =
Matrix.of(context).client.getRoomById(controller.widget.roomId);
final powerLevelsContent = Map<String, dynamic>.from(
room.getState(EventTypes.RoomPowerLevels).content);
final powerLevels = Map<String, dynamic>.from(powerLevelsContent)
..removeWhere((k, v) => !(v is int));
final eventsPowerLevels =
Map<String, dynamic>.from(powerLevelsContent['events'] ?? {})
..removeWhere((k, v) => !(v is int));
return Column(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var entry in powerLevels.entries)
PermissionsListTile(
permissionKey: entry.key,
permission: entry.value,
onTap: () => controller.editPowerLevel(
context, entry.key, entry.value),
),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).notifications,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
Builder(builder: (context) {
final key = 'rooms';
final int value = powerLevelsContent
.containsKey('notifications')
? powerLevelsContent['notifications']['rooms'] ?? 0
: 0;
return PermissionsListTile(
permissionKey: key,
permission: value,
category: 'notifications',
onTap: () => controller.editPowerLevel(
context, key, value,
category: 'notifications'),
);
}),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).configureChat,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
if (eventsPowerLevels != null)
for (var entry in eventsPowerLevels.entries)
PermissionsListTile(
permissionKey: entry.key,
category: 'events',
permission: entry.value,
onTap: () => controller.editPowerLevel(
context, entry.key, entry.value,
category: 'events'),
),
if (room.canSendEvent(EventTypes.RoomTombstone)) ...{
Divider(thickness: 1),
FutureBuilder<ServerCapabilities>(
future: room.client.getCapabilities(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
}
final String roomVersion = room
.getState(EventTypes.RoomCreate)
.content['room_version'] ??
'1';
return ListTile(
title: Text(
'${L10n.of(context).roomVersion}: $roomVersion'),
onTap: () =>
controller.updateRoomAction(snapshot.data),
);
},
),
},
],
),
],
);
},
),
),
);
}
}

View file

@ -0,0 +1,756 @@
import 'dart:math';
import 'dart:ui';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/chat.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/chat_settings_popup_menu.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
import 'package:fluffychat/widgets/input_bar.dart';
import 'package:fluffychat/widgets/unread_badge_back_button.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/encryption_button.dart';
import 'package:fluffychat/widgets/list_items/message.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/reply_content.dart';
import 'package:fluffychat/pages/user_bottom_sheet.dart';
import 'package:fluffychat/config/app_emojis.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/room_status_extension.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:swipe_to_action/swipe_to_action.dart';
class ChatUI extends StatelessWidget {
final ChatController controller;
const ChatUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
controller.matrix = Matrix.of(context);
final client = controller.matrix.client;
controller.room ??= client.getRoomById(controller.widget.id);
if (controller.room == null) {
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).oopsSomethingWentWrong),
),
body: Center(
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
);
}
controller.matrix.client.activeRoomId = controller.widget.id;
if (controller.room.membership == Membership.invite) {
showFutureLoadingDialog(
context: context, future: () => controller.room.join());
}
return Scaffold(
appBar: AppBar(
leading: controller.selectMode
? IconButton(
icon: Icon(Icons.close),
onPressed: controller.clearSelectedEvents,
tooltip: L10n.of(context).close,
)
: AdaptivePageLayout.of(context).columnMode(context)
? null
: UnreadBadgeBackButton(roomId: controller.widget.id),
titleSpacing:
AdaptivePageLayout.of(context).columnMode(context) ? null : 0,
title: controller.selectedEvents.isEmpty
? StreamBuilder(
stream: controller.room.onUpdate.stream,
builder: (context, snapshot) => ListTile(
leading: Avatar(
controller.room.avatar, controller.room.displayname),
contentPadding: EdgeInsets.zero,
onTap: controller.room.isDirectChat
? () => showModalBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
user: controller.room.getUserByMXIDSync(
controller.room.directChatMatrixID),
outerContext: context,
onMention: () => controller
.sendController.text +=
'${controller.room.directChatMatrixID} ',
),
)
: () => (!AdaptivePageLayout.of(context)
.columnMode(context) ||
AdaptivePageLayout.of(context)
.viewDataStack
.length <
3)
? AdaptivePageLayout.of(context).pushNamed(
'/rooms/${controller.room.id}/details')
: null,
title: Text(
controller.room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context))),
maxLines: 1),
subtitle: controller.room
.getLocalizedTypingText(context)
.isEmpty
? StreamBuilder<Object>(
stream: Matrix.of(context)
.client
.onPresence
.stream
.where((p) =>
p.senderId ==
controller.room.directChatMatrixID),
builder: (context, snapshot) => Text(
controller.room.getLocalizedStatus(context),
maxLines: 1,
//overflow: TextOverflow.ellipsis,
))
: Row(
children: <Widget>[
Icon(Icons.edit_outlined,
color: Theme.of(context).accentColor,
size: 13),
SizedBox(width: 4),
Expanded(
child: Text(
controller.room
.getLocalizedTypingText(context),
maxLines: 1,
style: TextStyle(
color: Theme.of(context).accentColor,
fontStyle: FontStyle.italic,
),
),
),
],
),
))
: Text(L10n.of(context)
.numberSelected(controller.selectedEvents.length.toString())),
actions: controller.selectMode
? <Widget>[
if (controller.selectedEvents.length == 1 &&
controller.selectedEvents.first.status > 0 &&
controller.selectedEvents.first.senderId == client.userID)
IconButton(
icon: Icon(Icons.edit_outlined),
tooltip: L10n.of(context).edit,
onPressed: controller.editSelectedEventAction,
),
PopupMenuButton(
onSelected: controller.onEventActionPopupMenuSelected,
itemBuilder: (_) => [
PopupMenuItem(
value: 'copy',
child: Text(L10n.of(context).copy),
),
if (controller.canRedactSelectedEvents)
PopupMenuItem(
value: 'redact',
child: Text(
L10n.of(context).redactMessage,
style: TextStyle(color: Colors.orange),
),
),
if (controller.selectedEvents.length == 1)
PopupMenuItem(
value: 'report',
child: Text(
L10n.of(context).reportMessage,
style: TextStyle(color: Colors.red),
),
),
],
),
]
: <Widget>[
if (controller.room.canSendDefaultStates)
IconButton(
tooltip: L10n.of(context).videoCall,
icon: Icon(Icons.video_call_outlined),
onPressed: controller.startCallAction,
),
ChatSettingsPopupMenu(
controller.room, !controller.room.isDirectChat),
],
),
floatingActionButton: controller.showScrollDownButton
? Padding(
padding: const EdgeInsets.only(bottom: 56.0),
child: FloatingActionButton(
onPressed: controller.scrollDown,
foregroundColor: Theme.of(context).textTheme.bodyText2.color,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
mini: true,
child: Icon(Icons.arrow_downward_outlined,
color: Theme.of(context).primaryColor),
),
)
: null,
body: Stack(
children: <Widget>[
if (Matrix.of(context).wallpaper != null)
Image.file(
Matrix.of(context).wallpaper,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
SafeArea(
child: Column(
children: <Widget>[
ConnectionStatusHeader(),
if (controller.room.getState(EventTypes.RoomTombstone) != null)
Container(
height: 72,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
child: ListTile(
leading: CircleAvatar(
foregroundColor: Theme.of(context).accentColor,
backgroundColor: Theme.of(context).backgroundColor,
child: Icon(Icons.upgrade_outlined),
),
title: Text(
controller.room
.getState(EventTypes.RoomTombstone)
.parsedTombstoneContent
.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(L10n.of(context).goToTheNewRoom),
onTap: controller.goToNewRoomAction,
),
),
),
Expanded(
child: FutureBuilder<bool>(
future: controller.getTimeline(),
builder: (BuildContext context, snapshot) {
if (controller.timeline == null) {
return Center(
child: CircularProgressIndicator(),
);
}
// create a map of eventId --> index to greatly improve performance of
// ListView's findChildIndexCallback
final thisEventsKeyMap = <String, int>{};
for (var i = 0;
i < controller.filteredEvents.length;
i++) {
thisEventsKeyMap[controller.filteredEvents[i].eventId] =
i;
}
final horizontalPadding = max(
0,
(MediaQuery.of(context).size.width -
FluffyThemes.columnWidth *
(AdaptivePageLayout.of(context)
.currentViewData
?.rightView !=
null
? 4.5
: 3.5)) /
2)
.toDouble();
return ListView.custom(
padding: EdgeInsets.only(
top: 16,
bottom: 4,
left: horizontalPadding,
right: horizontalPadding,
),
reverse: true,
controller: controller.scrollController,
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int i) {
return i == controller.filteredEvents.length + 1
? controller.timeline.isRequestingHistory
? Container(
height: 50,
alignment: Alignment.center,
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
)
: controller.canLoadMore
? TextButton(
onPressed:
controller.requestHistory,
child: Text(
L10n.of(context).loadMore,
style: TextStyle(
color: Theme.of(context)
.primaryColor,
fontWeight: FontWeight.bold,
decoration:
TextDecoration.underline,
),
),
)
: Container()
: i == 0
? StreamBuilder(
stream: controller.room.onUpdate.stream,
builder: (_, __) {
final seenByText = controller.room
.getLocalizedSeenByText(
context,
controller.timeline,
controller.filteredEvents,
controller.unfolded,
);
return AnimatedContainer(
height: seenByText.isEmpty ? 0 : 24,
duration: seenByText.isEmpty
? Duration(milliseconds: 0)
: Duration(milliseconds: 300),
alignment: controller.filteredEvents
.first.senderId ==
client.userID
? Alignment.topRight
: Alignment.topLeft,
padding: EdgeInsets.only(
left: 8,
right: 8,
bottom: 8,
),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 4),
decoration: BoxDecoration(
color: Theme.of(context)
.scaffoldBackgroundColor
.withOpacity(0.8),
borderRadius:
BorderRadius.circular(4),
),
child: Text(
seenByText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context)
.accentColor,
),
),
),
);
},
)
: AutoScrollTag(
key: ValueKey(controller
.filteredEvents[i - 1].eventId),
index: i - 1,
controller: controller.scrollController,
child: Swipeable(
key: ValueKey(controller
.filteredEvents[i - 1].eventId),
background: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.0),
child: Center(
child: Icon(Icons.reply_outlined),
),
),
direction: SwipeDirection.endToStart,
onSwipe: (direction) =>
controller.replyAction(
replyTo: controller
.filteredEvents[i - 1]),
child: Message(
controller.filteredEvents[i - 1],
onAvatarTab: (Event event) =>
showModalBottomSheet(
context: context,
builder: (c) =>
UserBottomSheet(
user: event.sender,
outerContext: context,
onMention: () => controller
.sendController
.text +=
'${event.senderId} ',
),
),
unfold: controller.unfold,
onSelect:
controller.onSelectMessage,
scrollToEventId:
(String eventId) => controller
.scrollToEventId(eventId),
longPressSelect: controller
.selectedEvents.isEmpty,
selected: controller
.selectedEvents
.contains(controller
.filteredEvents[i - 1]),
timeline: controller.timeline,
nextEvent: i >= 2
? controller
.filteredEvents[i - 2]
: null),
),
);
},
childCount: controller.filteredEvents.length + 2,
findChildIndexCallback: (key) => controller
.findChildIndexCallback(key, thisEventsKeyMap),
),
);
},
),
),
if (!controller.showEmojiPicker)
AnimatedContainer(
duration: Duration(milliseconds: 300),
height: (controller.editEvent == null &&
controller.replyEvent == null &&
controller.room.canSendDefaultMessages &&
controller.selectedEvents.length == 1)
? 56
: 0,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
child: Builder(builder: (context) {
if (!(controller.editEvent == null &&
controller.replyEvent == null &&
controller.selectedEvents.length == 1)) {
return Container();
}
final emojis = List<String>.from(AppEmojis.emojis);
final allReactionEvents = controller
.selectedEvents.first
.aggregatedEvents(
controller.timeline, RelationshipTypes.reaction)
?.where((event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction');
allReactionEvents.forEach((event) {
try {
emojis.remove(event.content['m.relates_to']['key']);
} catch (_) {}
});
return ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: emojis.length + 1,
itemBuilder: (c, i) => i == emojis.length
? InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => controller
.pickEmojiAction(allReactionEvents),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Icon(Icons.add_outlined),
),
)
: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () =>
controller.sendEmojiAction(emojis[i]),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
emojis[i],
style: TextStyle(fontSize: 30),
),
),
),
);
}),
),
),
AnimatedContainer(
duration: Duration(milliseconds: 300),
height: controller.editEvent != null ||
controller.replyEvent != null
? 56
: 0,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
child: Row(
children: <Widget>[
IconButton(
tooltip: L10n.of(context).close,
icon: Icon(Icons.close),
onPressed: controller.cancelReplyEventAction,
),
Expanded(
child: controller.replyEvent != null
? ReplyContent(controller.replyEvent,
timeline: controller.timeline)
: _EditContent(controller.editEvent
?.getDisplayEvent(controller.timeline)),
),
],
),
),
),
Divider(
height: 1,
thickness: 1,
),
if (controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join &&
!controller.showEmojiPicker)
Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: controller.selectMode
? <Widget>[
Container(
height: 56,
child: TextButton(
onPressed: controller.forwardEventsAction,
child: Row(
children: <Widget>[
Icon(Icons.keyboard_arrow_left_outlined),
Text(L10n.of(context).forward),
],
),
),
),
controller.selectedEvents.length == 1
? controller.selectedEvents.first
.getDisplayEvent(
controller.timeline)
.status >
0
? Container(
height: 56,
child: TextButton(
onPressed: controller.replyAction,
child: Row(
children: <Widget>[
Text(L10n.of(context).reply),
Icon(
Icons.keyboard_arrow_right),
],
),
),
)
: Container(
height: 56,
child: TextButton(
onPressed:
controller.sendAgainAction,
child: Row(
children: <Widget>[
Text(L10n.of(context)
.tryToSendAgain),
SizedBox(width: 4),
Icon(Icons.send_outlined,
size: 16),
],
),
),
)
: Container(),
]
: <Widget>[
AnimatedContainer(
duration: Duration(milliseconds: 200),
height: 56,
width: controller.inputText.isEmpty ? 56 : 0,
alignment: Alignment.center,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(),
child: PopupMenuButton<String>(
icon: Icon(Icons.add_outlined),
onSelected:
controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'file',
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
child:
Icon(Icons.attachment_outlined),
),
title: Text(L10n.of(context).sendFile),
contentPadding: EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'image',
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
child: Icon(Icons.image_outlined),
),
title: Text(L10n.of(context).sendImage),
contentPadding: EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera',
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
child:
Icon(Icons.camera_alt_outlined),
),
title:
Text(L10n.of(context).openCamera),
contentPadding: EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'voice',
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child:
Icon(Icons.mic_none_outlined),
),
title: Text(
L10n.of(context).voiceMessage),
contentPadding: EdgeInsets.all(0),
),
),
],
),
),
Container(
height: 56,
alignment: Alignment.center,
child: EncryptionButton(controller.room),
),
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 4.0),
child: InputBar(
room: controller.room,
minLines: 1,
maxLines: kIsWeb ? 1 : 8,
autofocus: !PlatformInfos.isMobile,
keyboardType: !PlatformInfos.isMobile
? TextInputType.text
: TextInputType.multiline,
onSubmitted: controller.onInputBarSubmitted,
focusNode: controller.inputFocus,
controller: controller.sendController,
decoration: InputDecoration(
hintText: L10n.of(context).writeAMessage,
hintMaxLines: 1,
border: InputBorder.none,
enabledBorder: InputBorder.none,
filled: false,
),
onChanged: controller.onInputBarChanged,
),
),
),
if (PlatformInfos.isMobile &&
controller.inputText.isEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
tooltip: L10n.of(context).voiceMessage,
icon: Icon(Icons.mic_none_outlined),
onPressed: controller.voiceMessageAction,
),
),
if (!PlatformInfos.isMobile ||
controller.inputText.isNotEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
icon: Icon(Icons.send_outlined),
onPressed: controller.send,
tooltip: L10n.of(context).send,
),
),
],
),
),
AnimatedContainer(
duration: Duration(milliseconds: 300),
height: controller.showEmojiPicker
? MediaQuery.of(context).size.height / 2
: 0,
child: controller.showEmojiPicker
? EmojiPicker(
onEmojiSelected: controller.onEmojiSelected,
onBackspacePressed: controller.cancelEmojiPicker,
)
: null,
),
],
),
),
],
),
);
}
}
class _EditContent extends StatelessWidget {
final Event event;
_EditContent(this.event);
@override
Widget build(BuildContext context) {
if (event == null) {
return Container();
}
return Row(
children: <Widget>[
Icon(
Icons.edit,
color: Theme.of(context).primaryColor,
),
Container(width: 15.0),
Text(
event?.getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false,
hideReply: true,
) ??
'',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: Theme.of(context).textTheme.bodyText2.color,
),
),
],
);
}
}

View file

@ -0,0 +1,97 @@
import 'package:fluffychat/pages/device_settings.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../widgets/list_items/user_device_list_item.dart';
class DevicesSettingsUI extends StatelessWidget {
final DevicesSettingsController controller;
const DevicesSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).devices),
),
body: MaxWidthBody(
child: FutureBuilder<bool>(
future: controller.loadUserDevices(context),
builder: (BuildContext context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.error_outlined),
Text(snapshot.error.toString()),
],
),
);
}
if (!snapshot.hasData || controller.devices == null) {
return Center(child: CircularProgressIndicator());
}
return Column(
children: <Widget>[
if (controller.thisDevice != null)
UserDeviceListItem(
controller.thisDevice,
rename: controller.renameDeviceAction,
remove: (d) => controller.removeDevicesAction([d]),
verify: controller.verifyDeviceAction,
block: controller.blockDeviceAction,
unblock: controller.unblockDeviceAction,
),
Divider(height: 1),
if (controller.notThisDevice.isNotEmpty)
ListTile(
title: Text(
controller.errorDeletingDevices ??
L10n.of(context).removeAllOtherDevices,
style: TextStyle(color: Colors.red),
),
trailing: controller.loadingDeletingDevices
? CircularProgressIndicator()
: Icon(Icons.delete_outline),
onTap: controller.loadingDeletingDevices
? null
: () => controller
.removeDevicesAction(controller.notThisDevice),
),
Divider(height: 1),
Expanded(
child: controller.notThisDevice.isEmpty
? Center(
child: Icon(
Icons.devices_other,
size: 60,
color: Theme.of(context).secondaryHeaderColor,
),
)
: ListView.separated(
separatorBuilder: (BuildContext context, int i) =>
Divider(height: 1),
itemCount: controller.notThisDevice.length,
itemBuilder: (BuildContext context, int i) =>
UserDeviceListItem(
controller.notThisDevice[i],
rename: controller.renameDeviceAction,
remove: (d) => controller.removeDevicesAction([d]),
verify: controller.verifyDeviceAction,
block: controller.blockDeviceAction,
unblock: controller.unblockDeviceAction,
),
),
),
],
);
},
),
),
);
}
}

View file

@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class EmptyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Image.asset('assets/favicon.png', width: 100, height: 100),
),
);
}
}

View file

@ -0,0 +1,109 @@
import '../homeserver_picker.dart';
import 'package:fluffychat/widgets/default_app_bar_search_field.dart';
import 'package:fluffychat/widgets/fluffy_banner.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/widgets/layouts/one_page_card.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class HomeserverPickerUI extends StatelessWidget {
final HomeserverPickerController controller;
const HomeserverPickerUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return OnePageCard(
child: Scaffold(
appBar: AppBar(
titleSpacing: 8,
title: DefaultAppBarSearchField(
prefixText: 'https://',
hintText: L10n.of(context).enterYourHomeserver,
searchController: controller.homeserverController,
suffix: Icon(Icons.edit_outlined),
padding: EdgeInsets.zero,
onChanged: (s) => controller.domain = s,
readOnly: !AppConfig.allowOtherHomeservers,
onSubmit: (_) => controller.checkHomeserverAction(),
unfocusOnClear: false,
),
elevation: 0,
),
body: SafeArea(
child: ListView(
children: [
Hero(
tag: 'loginBanner',
child: FluffyBanner(),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
AppConfig.applicationWelcomeMessage ??
L10n.of(context).welcomeText,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
),
),
),
],
),
),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
Hero(
tag: 'loginButton',
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12),
child: ElevatedButton(
onPressed: controller.isLoading
? null
: controller.checkHomeserverAction,
child: controller.isLoading
? LinearProgressIndicator()
: Text(
L10n.of(context).connect.toUpperCase(),
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
),
Wrap(
alignment: WrapAlignment.center,
children: [
TextButton(
onPressed: () => launch(AppConfig.privacyUrl),
child: Text(
L10n.of(context).privacy,
style: TextStyle(
decoration: TextDecoration.underline,
color: Colors.blueGrey,
),
),
),
TextButton(
onPressed: () => PlatformInfos.showDialog(context),
child: Text(
L10n.of(context).about,
style: TextStyle(
decoration: TextDecoration.underline,
color: Colors.blueGrey,
),
),
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,59 @@
import '../image_viewer.dart';
import 'package:fluffychat/widgets/image_bubble.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ImageViewerUI extends StatelessWidget {
final ImageViewerController controller;
const ImageViewerUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
leading: IconButton(
icon: Icon(Icons.close),
onPressed: Navigator.of(context, rootNavigator: false).pop,
color: Colors.white,
tooltip: L10n.of(context).close,
),
backgroundColor: Color(0x44000000),
actions: [
IconButton(
icon: Icon(Icons.reply_outlined),
onPressed: controller.forwardAction,
color: Colors.white,
tooltip: L10n.of(context).share,
),
IconButton(
icon: Icon(Icons.download_outlined),
onPressed: controller.openFileAction,
color: Colors.white,
tooltip: L10n.of(context).downloadFile,
),
],
),
body: InteractiveViewer(
minScale: 1.0,
maxScale: 10.0,
onInteractionEnd: controller.onInteractionEnds,
child: Center(
child: ImageBubble(
controller.widget.event,
tapToView: false,
onLoaded: controller.widget.onLoaded,
fit: BoxFit.contain,
backgroundColor: Colors.black,
maxSize: false,
radius: 0.0,
thumbnailOnly: false,
),
),
),
);
}
}

View file

@ -0,0 +1,83 @@
import 'package:fluffychat/pages/invitation_selection.dart';
import 'package:fluffychat/widgets/default_app_bar_search_field.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class InvitationSelectionUI extends StatelessWidget {
final InvitationSelectionController controller;
const InvitationSelectionUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final room =
Matrix.of(context).client.getRoomById(controller.widget.roomId);
final groupName =
room.name?.isEmpty ?? false ? L10n.of(context).group : room.name;
return Scaffold(
appBar: AppBar(
leading: BackButton(),
titleSpacing: 0,
title: DefaultAppBarSearchField(
autofocus: true,
hintText: L10n.of(context).inviteContactToGroup(groupName),
onChanged: controller.searchUserWithCoolDown,
),
),
body: MaxWidthBody(
withScrolling: true,
child: controller.foundProfiles.isNotEmpty
? ListView.builder(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) => ListTile(
leading: Avatar(
controller.foundProfiles[i].avatarUrl,
controller.foundProfiles[i].displayname ??
controller.foundProfiles[i].userId,
),
title: Text(
controller.foundProfiles[i].displayname ??
controller.foundProfiles[i].userId.localpart,
),
subtitle: Text(controller.foundProfiles[i].userId),
onTap: () => controller.inviteAction(
context, controller.foundProfiles[i].userId),
),
)
: FutureBuilder<List<User>>(
future: controller.getContacts(context),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData) {
return Center(
child: CircularProgressIndicator(),
);
}
final contacts = snapshot.data;
return ListView.builder(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: contacts.length,
itemBuilder: (BuildContext context, int i) => ListTile(
leading: Avatar(
contacts[i].avatarUrl,
contacts[i].calcDisplayname(),
),
title: Text(contacts[i].calcDisplayname()),
subtitle: Text(contacts[i].id),
onTap: () =>
controller.inviteAction(context, contacts[i].id),
),
);
},
),
),
);
}
}

View file

@ -0,0 +1,285 @@
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:fluffychat/widgets/layouts/one_page_card.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/platform_infos.dart';
import 'package:email_validator/email_validator.dart';
class Login extends StatefulWidget {
@override
_LoginState createState() => _LoginState();
}
class _LoginState extends State<Login> {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
String usernameError;
String passwordError;
bool loading = false;
bool showPassword = false;
void login(BuildContext context) async {
final matrix = Matrix.of(context);
if (usernameController.text.isEmpty) {
setState(() => usernameError = L10n.of(context).pleaseEnterYourUsername);
} else {
setState(() => usernameError = null);
}
if (passwordController.text.isEmpty) {
setState(() => passwordError = L10n.of(context).pleaseEnterYourPassword);
} else {
setState(() => passwordError = null);
}
if (usernameController.text.isEmpty || passwordController.text.isEmpty) {
return;
}
setState(() => loading = true);
try {
final username = usernameController.text;
AuthenticationIdentifier identifier;
if (username.isEmail) {
identifier = AuthenticationThirdPartyIdentifier(
medium: 'email',
address: username,
);
} else if (username.isPhoneNumber) {
identifier = AuthenticationThirdPartyIdentifier(
medium: 'msisdn',
address: username,
);
} else {
identifier = AuthenticationUserIdentifier(user: username);
}
await matrix.client.login(
identifier: identifier,
// To stay compatible with older server versions
// ignore: deprecated_member_use
user: identifier.type == AuthenticationIdentifierTypes.userId
? username
: null,
password: passwordController.text,
initialDeviceDisplayName: PlatformInfos.clientName);
} on MatrixException catch (exception) {
setState(() => passwordError = exception.errorMessage);
return setState(() => loading = false);
} catch (exception) {
setState(() => passwordError = exception.toString());
return setState(() => loading = false);
}
if (mounted) setState(() => loading = false);
}
Timer _coolDown;
void _checkWellKnownWithCoolDown(String userId, BuildContext context) async {
_coolDown?.cancel();
_coolDown = Timer(
Duration(seconds: 1),
() => _checkWellKnown(userId, context),
);
}
void _checkWellKnown(String userId, BuildContext context) async {
setState(() => usernameError = null);
if (!userId.isValidMatrixId) return;
try {
final wellKnownInformations = await Matrix.of(context)
.client
.getWellKnownInformationsByUserId(userId);
final newDomain = wellKnownInformations.mHomeserver?.baseUrl;
if ((newDomain?.isNotEmpty ?? false) &&
newDomain != Matrix.of(context).client.homeserver.toString()) {
await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.checkHomeserver(newDomain),
);
setState(() => usernameError = null);
}
} catch (e) {
setState(() => usernameError = e.toString());
}
}
void _passwordForgotten(BuildContext context) async {
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).enterAnEmailAddress,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: L10n.of(context).enterAnEmailAddress,
keyboardType: TextInputType.emailAddress,
),
],
);
if (input == null) return;
final clientSecret = DateTime.now().millisecondsSinceEpoch.toString();
final response = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.resetPasswordUsingEmail(
input.single,
clientSecret,
sendAttempt++,
),
);
if (response.error != null) return;
final ok = await showOkAlertDialog(
context: context,
title: L10n.of(context).weSentYouAnEmail,
message: L10n.of(context).pleaseClickOnLink,
okLabel: L10n.of(context).iHaveClickedOnLink,
useRootNavigator: false,
);
if (ok == null) return;
final password = await showTextInputDialog(
context: context,
title: L10n.of(context).chooseAStrongPassword,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
textFields: [
DialogTextField(
hintText: '******',
obscureText: true,
minLines: 1,
maxLines: 1,
),
],
);
if (password == null) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.changePassword(
password.single,
auth: AuthenticationThreePidCreds(
type: AuthenticationTypes.emailIdentity,
threepidCreds: [
ThreepidCreds(
sid: (response as RequestTokenResponse).sid,
clientSecret: clientSecret,
),
],
),
),
);
if (success.error == null) {
AdaptivePageLayout.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).passwordHasBeenChanged)));
}
}
static int sendAttempt = 0;
@override
Widget build(BuildContext context) {
return OnePageCard(
child: Scaffold(
appBar: AppBar(
leading: loading ? Container() : BackButton(),
elevation: 0,
title: Text(
L10n.of(context).logInTo(Matrix.of(context)
.client
.homeserver
.toString()
.replaceFirst('https://', '')),
),
),
body: Builder(builder: (context) {
return ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(12.0),
child: TextField(
readOnly: loading,
autocorrect: false,
autofocus: true,
onChanged: (t) => _checkWellKnownWithCoolDown(t, context),
controller: usernameController,
autofillHints: loading ? null : [AutofillHints.username],
decoration: InputDecoration(
prefixIcon: Icon(Icons.account_box_outlined),
hintText:
'@${L10n.of(context).username.toLowerCase()}:domain',
errorText: usernameError,
labelText: L10n.of(context).username),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: TextField(
readOnly: loading,
autocorrect: false,
autofillHints: loading ? null : [AutofillHints.password],
controller: passwordController,
obscureText: !showPassword,
onSubmitted: (t) => login(context),
decoration: InputDecoration(
prefixIcon: Icon(Icons.lock_outlined),
hintText: '****',
errorText: passwordError,
suffixIcon: IconButton(
tooltip: L10n.of(context).showPassword,
icon: Icon(showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined),
onPressed: () =>
setState(() => showPassword = !showPassword),
),
labelText: L10n.of(context).password),
),
),
SizedBox(height: 12),
Hero(
tag: 'loginButton',
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: ElevatedButton(
onPressed: loading ? null : () => login(context),
child: loading
? LinearProgressIndicator()
: Text(
L10n.of(context).login.toUpperCase(),
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
),
Center(
child: TextButton(
onPressed: () => _passwordForgotten(context),
child: Text(
L10n.of(context).passwordForgotten,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
),
],
);
}),
),
);
}
}
extension on String {
static final RegExp _phoneRegex =
RegExp(r'^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*$');
bool get isEmail => EmailValidator.validate(this);
bool get isPhoneNumber => _phoneRegex.hasMatch(this);
}

View file

@ -0,0 +1,54 @@
import 'package:fluffychat/pages/new_group.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class NewGroupUI extends StatelessWidget {
final NewGroupController controller;
const NewGroupUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).createNewGroup),
elevation: 0,
),
body: MaxWidthBody(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(12.0),
child: TextField(
controller: controller.controller,
autofocus: true,
autocorrect: false,
textInputAction: TextInputAction.go,
onSubmitted: controller.submitAction,
decoration: InputDecoration(
labelText: L10n.of(context).optionalGroupName,
prefixIcon: Icon(Icons.people_outlined),
hintText: L10n.of(context).enterAGroupName),
),
),
SwitchListTile(
title: Text(L10n.of(context).groupIsPublic),
value: controller.publicGroup,
onChanged: controller.setPublicGroup,
),
Expanded(
child: Image.asset('assets/new_group_wallpaper.png'),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: controller.submitAction,
child: Icon(Icons.arrow_forward_outlined),
),
);
}
}

View file

@ -0,0 +1,133 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/pages/new_private_chat.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/contacts_list.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:famedlysdk/famedlysdk.dart';
class NewPrivateChatUI extends StatelessWidget {
final NewPrivateChatController controller;
const NewPrivateChatUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).newChat),
elevation: 0,
actions: [
TextButton(
onPressed: () => AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/newgroup'),
child: Text(
L10n.of(context).createNewGroup,
style: TextStyle(color: Theme.of(context).accentColor),
),
)
],
),
body: MaxWidthBody(
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(12.0),
child: Form(
key: controller.formKey,
child: TextFormField(
controller: controller.controller,
autocorrect: false,
onChanged: controller.searchUserWithCoolDown,
textInputAction: TextInputAction.go,
onFieldSubmitted: controller.submitAction,
validator: controller.validateForm,
decoration: InputDecoration(
labelText: L10n.of(context).enterAUsername,
prefixIcon: controller.loading
? Container(
padding: const EdgeInsets.all(8.0),
width: 12,
height: 12,
child: CircularProgressIndicator(),
)
: controller.correctMxId
? Padding(
padding: const EdgeInsets.all(8.0),
child: Avatar(
controller.foundProfile.avatarUrl,
controller.foundProfile.displayname ??
controller.foundProfile.userId,
size: 12,
),
)
: Icon(Icons.account_circle_outlined),
prefixText: '@',
suffixIcon: IconButton(
onPressed: controller.submitAction,
icon: Icon(Icons.arrow_forward_outlined),
),
hintText: '${L10n.of(context).username.toLowerCase()}',
),
),
),
),
Divider(height: 1),
ListTile(
leading: CircleAvatar(
radius: Avatar.defaultSize / 2,
foregroundColor: Theme.of(context).accentColor,
backgroundColor: Theme.of(context).secondaryHeaderColor,
child: Icon(Icons.share_outlined),
),
onTap: controller.inviteAction,
title: Text('${L10n.of(context).yourOwnUsername}:'),
subtitle: Text(
Matrix.of(context).client.userID,
style: TextStyle(color: Theme.of(context).accentColor),
),
),
Divider(height: 1),
if (controller.foundProfiles.isNotEmpty)
Expanded(
child: ListView.builder(
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) {
final foundProfile = controller.foundProfiles[i];
return ListTile(
onTap: () => controller.pickUser(foundProfile),
leading: Avatar(
foundProfile.avatarUrl,
foundProfile.displayname ?? foundProfile.userId,
//size: 24,
),
title: Text(
foundProfile.displayname ??
foundProfile.userId.localpart,
style: TextStyle(),
maxLines: 1,
),
subtitle: Text(
foundProfile.userId,
maxLines: 1,
style: TextStyle(
fontSize: 12,
),
),
);
},
),
),
if (controller.foundProfiles.isEmpty)
Expanded(
child: ContactsList(searchController: controller.controller),
),
],
),
),
);
}
}

View file

@ -0,0 +1,272 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/contacts_list.dart';
import 'package:fluffychat/widgets/default_app_bar_search_field.dart';
import 'package:fluffychat/widgets/list_items/chat_list_item.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import '../../utils/localized_exception_extension.dart';
import '../search.dart';
class SearchUI extends StatelessWidget {
final SearchController controller;
const SearchUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final server = controller.genericSearchTerm?.isValidMatrixId ?? false
? controller.genericSearchTerm.domain
: controller.server;
if (controller.lastServer != server) {
controller.lastServer = server;
controller.publicRoomsResponse = null;
}
controller.publicRoomsResponse ??= Matrix.of(context)
.client
.queryPublicRooms(
server: server,
genericSearchTerm: controller.genericSearchTerm,
)
.catchError((error) {
if (controller.widget.alias == null) {
throw error;
}
return PublicRoomsResponse.fromJson({
'chunk': [],
});
}).then((PublicRoomsResponse res) {
if (controller.widget.alias != null &&
!res.chunk.any((room) =>
(room.aliases?.contains(controller.widget.alias) ?? false) ||
room.canonicalAlias == controller.widget.alias)) {
// we have to tack on the original alias
res.chunk.add(PublicRoom.fromJson(<String, dynamic>{
'aliases': [controller.widget.alias],
'name': controller.widget.alias,
}));
}
return res;
});
final rooms = List<Room>.from(Matrix.of(context).client.rooms);
rooms.removeWhere(
(room) =>
room.lastEvent == null ||
!room.displayname
.toLowerCase()
.contains(controller.controller.text.toLowerCase()),
);
return DefaultTabController(
length: 3,
initialIndex: 1,
child: Scaffold(
appBar: AppBar(
leading: BackButton(),
titleSpacing: 0,
title: DefaultAppBarSearchField(
autofocus: true,
hintText: L10n.of(context).search,
searchController: controller.controller,
suffix: Icon(Icons.search_outlined),
onChanged: controller.search,
),
bottom: TabBar(
indicatorColor: Theme.of(context).accentColor,
labelColor: Theme.of(context).accentColor,
unselectedLabelColor: Theme.of(context).textTheme.bodyText1.color,
labelStyle: TextStyle(fontSize: 16),
labelPadding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
tabs: [
Tab(child: Text(L10n.of(context).discover, maxLines: 1)),
Tab(child: Text(L10n.of(context).chats, maxLines: 1)),
Tab(child: Text(L10n.of(context).people, maxLines: 1)),
],
),
),
body: TabBarView(
children: [
ListView(
children: [
SizedBox(height: 12),
ListTile(
leading: CircleAvatar(
foregroundColor: Theme.of(context).accentColor,
backgroundColor: Theme.of(context).secondaryHeaderColor,
child: Icon(Icons.edit_outlined),
),
title: Text(L10n.of(context).changeTheServer),
onTap: controller.setServer,
),
FutureBuilder<PublicRoomsResponse>(
future: controller.publicRoomsResponse,
builder: (BuildContext context,
AsyncSnapshot<PublicRoomsResponse> snapshot) {
if (snapshot.hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 32),
Icon(
Icons.error_outlined,
size: 80,
color: Colors.grey,
),
Center(
child: Text(
snapshot.error.toLocalizedString(context),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
),
],
);
}
if (snapshot.connectionState != ConnectionState.done) {
return Center(child: CircularProgressIndicator());
}
final publicRoomsResponse = snapshot.data;
if (publicRoomsResponse.chunk.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 32),
Icon(
Icons.search_outlined,
size: 80,
color: Colors.grey,
),
Center(
child: Text(
L10n.of(context).noPublicRoomsFound,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
),
],
);
}
return GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.all(12),
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: publicRoomsResponse.chunk.length,
itemBuilder: (BuildContext context, int i) => Material(
elevation: 2,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () => controller.joinGroupAction(
publicRoomsResponse.chunk[i],
),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
Uri.parse(publicRoomsResponse
.chunk[i].avatarUrl ??
''),
publicRoomsResponse.chunk[i].name),
Text(
publicRoomsResponse.chunk[i].name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
textAlign: TextAlign.center,
),
Text(
L10n.of(context).countParticipants(
publicRoomsResponse
.chunk[i].numJoinedMembers ??
0),
style: TextStyle(fontSize: 10.5),
maxLines: 1,
textAlign: TextAlign.center,
),
Text(
publicRoomsResponse.chunk[i].topic ??
L10n.of(context).noDescription,
maxLines: 4,
textAlign: TextAlign.center,
),
],
),
),
),
),
);
}),
],
),
ListView.builder(
itemCount: rooms.length,
itemBuilder: (_, i) => ChatListItem(rooms[i]),
),
controller.foundProfiles.isNotEmpty
? ListView.builder(
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) {
final foundProfile = controller.foundProfiles[i];
return ListTile(
onTap: () async {
final roomID = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context)
.client
.startDirectChat(foundProfile.userId),
);
if (roomID.error == null) {
await AdaptivePageLayout.of(context)
.popAndPushNamed('/rooms/${roomID.result}');
}
},
leading: Avatar(
foundProfile.avatarUrl,
foundProfile.displayname ?? foundProfile.userId,
//size: 24,
),
title: Text(
foundProfile.displayname ??
foundProfile.userId.localpart,
style: TextStyle(),
maxLines: 1,
),
subtitle: Text(
foundProfile.userId,
maxLines: 1,
style: TextStyle(
fontSize: 12,
),
),
);
},
)
: ContactsList(searchController: controller.controller),
],
),
),
);
}
}

View file

@ -0,0 +1,104 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/pages/settings_3pid.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class Settings3PidUI extends StatelessWidget {
final Settings3PidController controller;
const Settings3PidUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
controller.request ??= Matrix.of(context).client.getAccount3PIDs();
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).passwordRecovery),
actions: [
IconButton(
icon: Icon(Icons.add_outlined),
onPressed: controller.add3PidAction,
tooltip: L10n.of(context).addEmail,
)
],
),
body: MaxWidthBody(
child: FutureBuilder<List<ThirdPartyIdentifier>>(
future: controller.request,
builder: (BuildContext context,
AsyncSnapshot<List<ThirdPartyIdentifier>> snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
),
);
}
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
}
final identifier = snapshot.data;
return Column(
children: [
ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor:
identifier.isEmpty ? Colors.orange : Colors.grey,
child: Icon(
identifier.isEmpty
? Icons.warning_outlined
: Icons.info_outlined,
),
),
title: Text(
identifier.isEmpty
? L10n.of(context).noPasswordRecoveryDescription
: L10n.of(context)
.withTheseAddressesRecoveryDescription,
),
),
Divider(height: 1),
Expanded(
child: ListView.builder(
itemCount: identifier.length,
itemBuilder: (BuildContext context, int i) => ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(identifier[i].iconData)),
title: Text(identifier[i].address),
trailing: IconButton(
tooltip: L10n.of(context).delete,
icon: Icon(Icons.delete_forever_outlined),
color: Colors.red,
onPressed: () => controller.delete3Pid(identifier[i]),
),
),
),
),
],
);
},
),
),
);
}
}
extension on ThirdPartyIdentifier {
IconData get iconData {
switch (medium) {
case ThirdPartyIdentifierMedium.email:
return Icons.mail_outline_rounded;
case ThirdPartyIdentifierMedium.msisdn:
return Icons.phone_android_outlined;
}
return Icons.device_unknown_outlined;
}
}

View file

@ -0,0 +1,230 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../widgets/matrix.dart';
import '../settings_emotes.dart';
class EmotesSettingsUI extends StatelessWidget {
final EmotesSettingsController controller;
const EmotesSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).emoteSettings),
),
floatingActionButton: controller.showSave
? FloatingActionButton(
onPressed: controller.saveAction,
child: Icon(Icons.save_outlined, color: Colors.white),
)
: null,
body: MaxWidthBody(
child: StreamBuilder(
stream: controller.widget.room?.onUpdate?.stream,
builder: (context, snapshot) {
return Column(
children: <Widget>[
if (!controller.readonly)
Container(
padding: EdgeInsets.symmetric(
vertical: 8.0,
),
child: ListTile(
leading: Container(
width: 180.0,
height: 38,
padding: EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10)),
color: Theme.of(context).secondaryHeaderColor,
),
child: TextField(
controller: controller.newEmoteController,
autocorrect: false,
minLines: 1,
maxLines: 1,
decoration: InputDecoration(
hintText: L10n.of(context).emoteShortcode,
prefixText: ': ',
suffixText: ':',
prefixStyle: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
suffixStyle: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
border: InputBorder.none,
),
),
),
title: _EmoteImagePicker(
controller: controller.newMxcController,
onPressed: controller.emoteImagePickerAction,
),
trailing: InkWell(
onTap: controller.addEmoteAction,
child: Icon(
Icons.add_outlined,
color: Colors.green,
size: 32.0,
),
),
),
),
if (controller.widget.room != null)
ListTile(
title: Text(L10n.of(context).enableEmotesGlobally),
trailing: Switch(
value: controller.isGloballyActive(client),
onChanged: controller.setIsGloballyActive,
),
),
if (!controller.readonly || controller.widget.room != null)
Divider(
height: 2,
thickness: 2,
color: Theme.of(context).primaryColor,
),
Expanded(
child: controller.emotes.isEmpty
? Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
L10n.of(context).noEmotesFound,
style: TextStyle(fontSize: 20),
),
),
)
: ListView.separated(
separatorBuilder: (BuildContext context, int i) =>
Container(),
itemCount: controller.emotes.length + 1,
itemBuilder: (BuildContext context, int i) {
if (i >= controller.emotes.length) {
return Container(height: 70);
}
final emote = controller.emotes[i];
final textEditingController =
TextEditingController();
textEditingController.text = emote.emoteClean;
return ListTile(
leading: Container(
width: 180.0,
height: 38,
padding: EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius:
BorderRadius.all(Radius.circular(10)),
color:
Theme.of(context).secondaryHeaderColor,
),
child: TextField(
readOnly: controller.readonly,
controller: textEditingController,
autocorrect: false,
minLines: 1,
maxLines: 1,
decoration: InputDecoration(
hintText: L10n.of(context).emoteShortcode,
prefixText: ': ',
suffixText: ':',
prefixStyle: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
suffixStyle: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
border: InputBorder.none,
),
onSubmitted: (s) =>
controller.submitEmoteAction(
s,
emote,
textEditingController,
),
),
),
title: _EmoteImage(emote.mxc),
trailing: controller.readonly
? null
: InkWell(
onTap: () =>
controller.removeEmoteAction(emote),
child: Icon(
Icons.delete_forever_outlined,
color: Colors.red,
size: 32.0,
),
),
);
},
),
),
],
);
}),
),
);
}
}
class _EmoteImage extends StatelessWidget {
final String mxc;
_EmoteImage(this.mxc);
@override
Widget build(BuildContext context) {
final size = 38.0;
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final url = Uri.parse(mxc)?.getThumbnail(
Matrix.of(context).client,
width: size * devicePixelRatio,
height: size * devicePixelRatio,
method: ThumbnailMethod.scale,
);
return CachedNetworkImage(
imageUrl: url.toString(),
fit: BoxFit.contain,
width: size,
height: size,
);
}
}
class _EmoteImagePicker extends StatefulWidget {
final TextEditingController controller;
final void Function(TextEditingController) onPressed;
_EmoteImagePicker({@required this.controller, @required this.onPressed});
@override
_EmoteImagePickerState createState() => _EmoteImagePickerState();
}
class _EmoteImagePickerState extends State<_EmoteImagePicker> {
@override
Widget build(BuildContext context) {
if (widget.controller.text == null || widget.controller.text.isEmpty) {
return ElevatedButton(
onPressed: () async {},
child: Text(L10n.of(context).pickImage),
);
} else {
return _EmoteImage(widget.controller.text);
}
}
}

View file

@ -0,0 +1,94 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../settings_ignore_list.dart';
import '../../widgets/matrix.dart';
class SettingsIgnoreListUI extends StatelessWidget {
final SettingsIgnoreListController controller;
const SettingsIgnoreListUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).ignoredUsers),
),
body: MaxWidthBody(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller.controller,
autocorrect: false,
textInputAction: TextInputAction.done,
onSubmitted: (_) => controller.ignoreUser(context),
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: 'bad_guy:domain.abc',
prefixText: '@',
labelText: L10n.of(context).ignoreUsername,
suffixIcon: IconButton(
tooltip: L10n.of(context).ignore,
icon: Icon(Icons.done_outlined),
onPressed: () => controller.ignoreUser(context),
),
),
),
SizedBox(height: 16),
Text(
L10n.of(context).ignoreListDescription,
style: TextStyle(color: Colors.orange),
),
],
),
),
Divider(height: 1),
Expanded(
child: StreamBuilder<Object>(
stream: client.onAccountData.stream
.where((a) => a.type == 'm.ignored_user_list'),
builder: (context, snapshot) {
return ListView.builder(
itemCount: client.ignoredUsers.length,
itemBuilder: (c, i) => FutureBuilder<Profile>(
future:
client.getProfileFromUserId(client.ignoredUsers[i]),
builder: (c, s) => ListTile(
leading: Avatar(
s.data?.avatarUrl ?? Uri.parse(''),
s.data?.displayname ?? client.ignoredUsers[i],
),
title: Text(
s.data?.displayname ?? client.ignoredUsers[i]),
trailing: IconButton(
tooltip: L10n.of(context).delete,
icon: Icon(Icons.delete_forever_outlined),
onPressed: () => showFutureLoadingDialog(
context: context,
future: () =>
client.unignoreUser(client.ignoredUsers[i]),
),
),
),
),
);
}),
),
],
),
),
);
}
}

View file

@ -0,0 +1,62 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/pages/settings_multiple_emotes.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class MultipleEmotesSettingsUI extends StatelessWidget {
final MultipleEmotesSettingsController controller;
MultipleEmotesSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final room =
Matrix.of(context).client.getRoomById(controller.widget.roomId);
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).emotePacks),
),
body: StreamBuilder(
stream: room.onUpdate.stream,
builder: (context, snapshot) {
final packs =
room.states['im.ponies.room_emotes'] ?? <String, Event>{};
if (!packs.containsKey('')) {
packs[''] = null;
}
final keys = packs.keys.toList();
keys.sort();
return ListView.separated(
separatorBuilder: (BuildContext context, int i) => Container(),
itemCount: keys.length,
itemBuilder: (BuildContext context, int i) {
final event = packs[keys[i]];
var packName = keys[i].isNotEmpty ? keys[i] : 'Default Pack';
if (event != null && event.content['pack'] is Map) {
if (event.content['pack']['displayname'] is String) {
packName = event.content['pack']['displayname'];
} else if (event.content['pack']['name'] is String) {
packName = event.content['pack']['name'];
}
}
return ListTile(
title: Text(packName),
onTap: () async {
await AdaptivePageLayout.of(context).pushNamed(
'/settings/emotes',
arguments: {
'room': room,
'stateKey': keys[i],
},
);
},
);
});
},
),
);
}
}

View file

@ -0,0 +1,123 @@
import 'dart:io';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/localized_exception_extension.dart';
import '../settings_notifications.dart';
import '../../widgets/matrix.dart';
class SettingsNotificationsUI extends StatelessWidget {
final SettingsNotificationsController controller;
const SettingsNotificationsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).notifications),
),
body: MaxWidthBody(
withScrolling: true,
child: StreamBuilder(
stream: Matrix.of(context)
.client
.onAccountData
.stream
.where((event) => event.type == 'm.push_rules'),
builder: (BuildContext context, _) {
return Column(
children: [
SwitchListTile(
value: !Matrix.of(context).client.allPushNotificationsMuted,
title: Text(
L10n.of(context).notificationsEnabledForThisAccount),
onChanged: (_) => showFutureLoadingDialog(
context: context,
future: () =>
Matrix.of(context).client.setMuteAllPushNotifications(
!Matrix.of(context)
.client
.allPushNotificationsMuted,
),
),
),
if (!Matrix.of(context).client.allPushNotificationsMuted) ...{
if (!kIsWeb && Platform.isAndroid)
ListTile(
title: Text(L10n.of(context).soundVibrationLedColor),
trailing: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.edit_outlined),
),
onTap: controller.openAndroidNotificationSettingsAction,
),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).pushRules,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
for (var item in NotificationSettingsItem.items)
SwitchListTile(
value: controller.getNotificationSetting(item) ?? true,
title: Text(item.title(context)),
onChanged: (bool enabled) =>
controller.setNotificationSetting(item, enabled),
),
},
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).devices,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
FutureBuilder<List<Pusher>>(
future: Matrix.of(context).client.getPushers(),
builder: (context, snapshot) {
if (snapshot.hasError) {
Center(
child: Text(
snapshot.error.toLocalizedString(context),
),
);
}
if (snapshot.connectionState != ConnectionState.done) {
Center(child: CircularProgressIndicator());
}
final pushers = snapshot.data ?? [];
return ListView.builder(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: pushers.length,
itemBuilder: (_, i) => ListTile(
title: Text(
'${pushers[i].appDisplayName} - ${pushers[i].appId}'),
subtitle: Text(pushers[i].data.url.toString()),
),
);
},
),
],
);
}),
),
);
}
}

View file

@ -0,0 +1,118 @@
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../config/app_config.dart';
import '../../widgets/matrix.dart';
import '../settings_style.dart';
class SettingsStyleUI extends StatelessWidget {
final SettingsStyleController controller;
const SettingsStyleUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
controller.currentTheme ??= AdaptiveTheme.of(context).mode;
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).changeTheme),
),
body: MaxWidthBody(
withScrolling: true,
child: Column(
children: [
RadioListTile<AdaptiveThemeMode>(
groupValue: controller.currentTheme,
value: AdaptiveThemeMode.system,
title: Text(L10n.of(context).systemTheme),
onChanged: controller.switchTheme,
),
RadioListTile<AdaptiveThemeMode>(
groupValue: controller.currentTheme,
value: AdaptiveThemeMode.light,
title: Text(L10n.of(context).lightTheme),
onChanged: controller.switchTheme,
),
RadioListTile<AdaptiveThemeMode>(
groupValue: controller.currentTheme,
value: AdaptiveThemeMode.dark,
title: Text(L10n.of(context).darkTheme),
onChanged: controller.switchTheme,
),
Divider(height: 1),
ListTile(
title: Text(
L10n.of(context).wallpaper,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
if (Matrix.of(context).wallpaper != null)
ListTile(
title: Image.file(
Matrix.of(context).wallpaper,
height: 38,
fit: BoxFit.cover,
),
trailing: Icon(
Icons.delete_forever_outlined,
color: Colors.red,
),
onTap: controller.deleteWallpaperAction,
),
Builder(builder: (context) {
return ListTile(
title: Text(L10n.of(context).changeWallpaper),
trailing: Icon(Icons.wallpaper_outlined),
onTap: controller.setWallpaperAction,
);
}),
Divider(height: 1),
ListTile(
title: Text(
L10n.of(context).fontSize,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
subtitle: Text('(*${AppConfig.fontSizeFactor})'),
),
Container(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
decoration: BoxDecoration(
color: Theme.of(context).secondaryHeaderColor,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor',
style: TextStyle(
fontSize: Theme.of(context).textTheme.bodyText1.fontSize *
AppConfig.fontSizeFactor,
),
),
),
),
Slider(
min: 0.5,
max: 2.5,
divisions: 4,
value: AppConfig.fontSizeFactor,
semanticFormatterCallback: (d) => d.toString(),
onChanged: controller.changeFontSizeFactor,
),
],
),
),
);
}
}

View file

@ -0,0 +1,261 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/widgets/sentry_switch_list_tile.dart';
import 'package:fluffychat/widgets/settings_switch_list_tile.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/beautify_string_extension.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../widgets/content_banner.dart';
import '../../widgets/matrix.dart';
import '../../config/app_config.dart';
import '../../config/setting_keys.dart';
import '../settings.dart';
class SettingsUI extends StatelessWidget {
final SettingsController controller;
const SettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
return Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) =>
<Widget>[
SliverAppBar(
leading: IconButton(
icon: Icon(Icons.close_outlined),
onPressed: () => AdaptivePageLayout.of(context).popUntilIsFirst(),
),
expandedHeight: 300.0,
floating: true,
pinned: true,
title: Text(L10n.of(context).settings),
actions: [
FutureBuilder(
future: controller.crossSigningCachedFuture,
builder: (context, snapshot) {
final needsBootstrap = Matrix.of(context)
.client
.encryption
?.crossSigning
?.enabled ==
false ||
snapshot.data == false;
final isUnknownSession =
Matrix.of(context).client.isUnknownSession;
final displayHeader = needsBootstrap || isUnknownSession;
if (!displayHeader) return Container();
return TextButton.icon(
icon: Icon(Icons.cloud, color: Colors.red),
label: Text(
L10n.of(context).chatBackup,
style: TextStyle(color: Colors.red),
),
onPressed: controller.firstRunBootstrapAction,
);
}),
],
backgroundColor: Theme.of(context).appBarTheme.color,
flexibleSpace: FlexibleSpaceBar(
background: ContentBanner(controller.profile?.avatarUrl,
onEdit: controller.setAvatarAction),
),
),
],
body: ListView(
children: <Widget>[
ListTile(
title: Text(
L10n.of(context).notifications,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
trailing: Icon(Icons.notifications_outlined),
title: Text(L10n.of(context).notifications),
onTap: () => AdaptivePageLayout.of(context)
.pushNamed('/settings/notifications'),
),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).chat,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
title: Text(L10n.of(context).changeTheme),
onTap: () =>
AdaptivePageLayout.of(context).pushNamed('/settings/style'),
trailing: Icon(Icons.style_outlined),
),
SettingsSwitchListTile(
title: L10n.of(context).renderRichContent,
onChanged: (b) => AppConfig.renderHtml = b,
storeKey: SettingKeys.renderHtml,
defaultValue: AppConfig.renderHtml,
),
SettingsSwitchListTile(
title: L10n.of(context).hideRedactedEvents,
onChanged: (b) => AppConfig.hideRedactedEvents = b,
storeKey: SettingKeys.hideRedactedEvents,
defaultValue: AppConfig.hideRedactedEvents,
),
SettingsSwitchListTile(
title: L10n.of(context).hideUnknownEvents,
onChanged: (b) => AppConfig.hideUnknownEvents = b,
storeKey: SettingKeys.hideUnknownEvents,
defaultValue: AppConfig.hideUnknownEvents,
),
ListTile(
title: Text(L10n.of(context).emoteSettings),
onTap: () =>
AdaptivePageLayout.of(context).pushNamed('/settings/emotes'),
trailing: Icon(Icons.insert_emoticon_outlined),
),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).account,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
trailing: Icon(Icons.edit_outlined),
title: Text(L10n.of(context).editDisplayname),
subtitle: Text(
controller.profile?.displayname ?? client.userID.localpart),
onTap: controller.setDisplaynameAction,
),
ListTile(
trailing: Icon(Icons.phone_outlined),
title: Text(L10n.of(context).editJitsiInstance),
subtitle: Text(AppConfig.jitsiInstance),
onTap: controller.setJitsiInstanceAction,
),
ListTile(
trailing: Icon(Icons.devices_other_outlined),
title: Text(L10n.of(context).devices),
onTap: () =>
AdaptivePageLayout.of(context).pushNamed('/settings/devices'),
),
ListTile(
trailing: Icon(Icons.block_outlined),
title: Text(L10n.of(context).ignoredUsers),
onTap: () =>
AdaptivePageLayout.of(context).pushNamed('/settings/ignore'),
),
SentrySwitchListTile(),
Divider(thickness: 1),
ListTile(
trailing: Icon(Icons.security_outlined),
title: Text(
L10n.of(context).changePassword,
),
onTap: controller.changePasswordAccountAction,
),
ListTile(
trailing: Icon(Icons.email_outlined),
title: Text(L10n.of(context).passwordRecovery),
onTap: () =>
AdaptivePageLayout.of(context).pushNamed('/settings/3pid'),
),
ListTile(
trailing: Icon(Icons.exit_to_app_outlined),
title: Text(L10n.of(context).logout),
onTap: controller.logoutAction,
),
ListTile(
trailing: Icon(Icons.delete_forever_outlined),
title: Text(
L10n.of(context).deleteAccount,
style: TextStyle(color: Colors.red),
),
onTap: controller.deleteAccountAction,
),
if (client.encryption != null) ...{
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).security,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
),
if (PlatformInfos.isMobile)
ListTile(
trailing: Icon(Icons.lock_outlined),
title: Text(L10n.of(context).appLock),
onTap: controller.setAppLockAction,
),
ListTile(
title: Text(L10n.of(context).yourPublicKey),
onTap: () => showOkAlertDialog(
context: context,
title: L10n.of(context).yourPublicKey,
message: client.fingerprintKey.beautified,
okLabel: L10n.of(context).ok,
useRootNavigator: false,
),
trailing: Icon(Icons.vpn_key_outlined),
),
ListTile(
title: Text(L10n.of(context).cachedKeys),
trailing: Icon(Icons.wb_cloudy_outlined),
subtitle: Text(
'${client.encryption.keyManager.enabled ? L10n.of(context).onlineKeyBackupEnabled : L10n.of(context).onlineKeyBackupDisabled}\n${client.encryption.crossSigning.enabled ? L10n.of(context).crossSigningEnabled : L10n.of(context).crossSigningDisabled}'),
onTap: controller.bootstrapSettingsAction,
),
},
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).about,
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
onTap: () => AdaptivePageLayout.of(context).pushNamed('/logs'),
),
ListTile(
trailing: Icon(Icons.help_outlined),
title: Text(L10n.of(context).help),
onTap: () => launch(AppConfig.supportUrl),
),
ListTile(
trailing: Icon(Icons.privacy_tip_outlined),
title: Text(L10n.of(context).privacy),
onTap: () => launch(AppConfig.privacyUrl),
),
ListTile(
trailing: Icon(Icons.link_outlined),
title: Text(L10n.of(context).about),
onTap: () => PlatformInfos.showDialog(context),
),
],
),
),
);
}
}

View file

@ -0,0 +1,87 @@
import 'package:fluffychat/pages/sign_up_password.dart';
import 'package:fluffychat/widgets/layouts/one_page_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class SignUpPasswordUI extends StatelessWidget {
final SignUpPasswordController controller;
const SignUpPasswordUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return OnePageCard(
child: Scaffold(
appBar: AppBar(
elevation: 0,
leading: controller.loading ? Container() : BackButton(),
title: Text(
L10n.of(context).chooseAStrongPassword,
),
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(12.0),
child: TextField(
controller: controller.passwordController,
obscureText: !controller.showPassword,
autofocus: true,
readOnly: controller.loading,
autocorrect: false,
onSubmitted: (_) => controller.signUpAction,
autofillHints:
controller.loading ? null : [AutofillHints.newPassword],
decoration: InputDecoration(
prefixIcon: Icon(Icons.lock_outlined),
hintText: '****',
errorText: controller.passwordError,
suffixIcon: IconButton(
tooltip: L10n.of(context).showPassword,
icon: Icon(controller.showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined),
onPressed: controller.toggleShowPassword,
),
labelText: L10n.of(context).password),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: TextField(
controller: controller.emailController,
readOnly: controller.loading,
autocorrect: false,
keyboardType: TextInputType.emailAddress,
onSubmitted: (_) => controller.signUpAction,
decoration: InputDecoration(
prefixIcon: Icon(Icons.mail_outline_outlined),
errorText: controller.emailError,
hintText: 'email@example.com',
labelText: L10n.of(context).optionalAddEmail),
),
),
SizedBox(height: 12),
Hero(
tag: 'loginButton',
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: ElevatedButton(
onPressed:
controller.loading ? null : controller.signUpAction,
child: controller.loading
? LinearProgressIndicator()
: Text(
L10n.of(context).createAccountNow.toUpperCase(),
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,117 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/pages/sign_up.dart';
import 'package:fluffychat/widgets/fluffy_banner.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/layouts/one_page_card.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class SignUpUI extends StatelessWidget {
final SignUpController controller;
const SignUpUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return OnePageCard(
child: Scaffold(
appBar: AppBar(
elevation: 0,
leading: controller.loading ? Container() : BackButton(),
title: Text(
Matrix.of(context)
.client
.homeserver
.toString()
.replaceFirst('https://', ''),
),
),
body: ListView(children: <Widget>[
Hero(
tag: 'loginBanner',
child: FluffyBanner(),
),
SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: TextField(
readOnly: controller.loading,
autocorrect: false,
controller: controller.usernameController,
onSubmitted: controller.signUpAction,
autofillHints:
controller.loading ? null : [AutofillHints.newUsername],
decoration: InputDecoration(
prefixIcon: Icon(Icons.account_circle_outlined),
hintText: L10n.of(context).username,
errorText: controller.usernameError,
labelText: L10n.of(context).chooseAUsername,
),
),
),
SizedBox(height: 8),
ListTile(
leading: CircleAvatar(
backgroundImage: controller.avatar == null
? null
: MemoryImage(controller.avatar.bytes),
backgroundColor: controller.avatar == null
? Theme.of(context).brightness == Brightness.dark
? Color(0xff121212)
: Colors.white
: Theme.of(context).secondaryHeaderColor,
child: controller.avatar == null
? Icon(Icons.camera_alt_outlined,
color: Theme.of(context).primaryColor)
: null,
),
trailing: controller.avatar == null
? null
: Icon(
Icons.close,
color: Colors.red,
),
title: Text(controller.avatar == null
? L10n.of(context).setAProfilePicture
: L10n.of(context).discardPicture),
onTap: controller.avatar == null
? controller.setAvatarAction
: controller.resetAvatarAction,
),
SizedBox(height: 16),
Hero(
tag: 'loginButton',
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: ElevatedButton(
onPressed: controller.loading ? null : controller.signUpAction,
child: controller.loading
? LinearProgressIndicator()
: Text(
L10n.of(context).signUp.toUpperCase(),
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
),
Center(
child: TextButton(
onPressed: () =>
AdaptivePageLayout.of(context).pushNamed('/login'),
child: Text(
L10n.of(context).alreadyHaveAnAccount,
style: TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
fontSize: 16,
),
),
),
),
]),
),
);
}
}

View file

@ -0,0 +1,157 @@
import 'dart:math';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:flutter/material.dart';
import '../../widgets/content_banner.dart';
import '../user_bottom_sheet.dart';
import '../../utils/presence_extension.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class UserBottomSheetUI extends StatelessWidget {
final UserBottomSheetController controller;
const UserBottomSheetUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final user = controller.widget.user;
final client = user.room.client;
final presence = client.presences[user.id];
return Center(
child: Container(
width: min(
MediaQuery.of(context).size.width, FluffyThemes.columnWidth * 1.5),
child: Material(
elevation: 4,
child: SafeArea(
child: Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
backgroundColor:
Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5),
leading: IconButton(
icon: Icon(Icons.arrow_downward_outlined),
onPressed: Navigator.of(context, rootNavigator: false).pop,
tooltip: L10n.of(context).close,
),
title: Text(user.calcDisplayname()),
actions: [
if (user.id != user.room.client.userID)
PopupMenuButton(
itemBuilder: (_) => [
if (controller.widget.onMention != null)
PopupMenuItem(
value: 'mention',
child: _TextWithIcon(
L10n.of(context).mention,
Icons.alternate_email_outlined,
),
),
if (user.id != user.room.client.userID &&
!user.room.isDirectChat)
PopupMenuItem(
value: 'message',
child: _TextWithIcon(
L10n.of(context).sendAMessage,
Icons.send_outlined,
),
),
if (user.canChangePowerLevel)
PopupMenuItem(
value: 'permission',
child: _TextWithIcon(
L10n.of(context).setPermissionsLevel,
Icons.edit_attributes_outlined,
),
),
if (user.canKick)
PopupMenuItem(
value: 'kick',
child: _TextWithIcon(
L10n.of(context).kickFromChat,
Icons.exit_to_app_outlined,
),
),
if (user.canBan && user.membership != Membership.ban)
PopupMenuItem(
value: 'ban',
child: _TextWithIcon(
L10n.of(context).banFromChat,
Icons.warning_sharp,
),
)
else if (user.canBan &&
user.membership == Membership.ban)
PopupMenuItem(
value: 'unban',
child: _TextWithIcon(
L10n.of(context).removeExile,
Icons.warning_outlined,
),
),
],
onSelected: controller.participantAction,
),
],
),
body: Column(
children: [
Expanded(
child: ContentBanner(
user.avatarUrl,
defaultIcon: Icons.person_outline,
client: user.room.client,
),
),
ListTile(
title: Text(L10n.of(context).username),
subtitle: Text(user.id),
trailing: Icon(Icons.share_outlined),
onTap: () => FluffyShare.share(
user.id, controller.widget.outerContext),
),
if (presence != null)
ListTile(
title: Text(presence.getLocalizedStatusMessage(context)),
subtitle:
Text(presence.getLocalizedLastActiveAgo(context)),
trailing: Icon(Icons.circle,
color: presence.presence.currentlyActive ?? false
? Colors.green
: Colors.grey),
),
],
),
),
),
),
),
);
}
}
class _TextWithIcon extends StatelessWidget {
final String text;
final IconData iconData;
const _TextWithIcon(
this.text,
this.iconData, {
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(iconData),
SizedBox(width: 16),
Text(text),
],
);
}
}