refactor: Folder structure and MVC chat ui

This commit is contained in:
Christian Pauly 2021-04-15 13:03:14 +02:00
commit cd9b4ee46b
47 changed files with 1648 additions and 1582 deletions

23
lib/views/archive.dart Normal file
View file

@ -0,0 +1,23 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/ui/archive_ui.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:flutter/material.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));
@override
Widget build(BuildContext context) => ArchiveUI(this);
}

File diff suppressed because it is too large Load diff

View file

@ -1,369 +1,223 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/controllers/chat_details_controller.dart';
import 'package:fluffychat/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/widgets/chat_settings_popup_menu.dart';
import 'package:fluffychat/views/widgets/content_banner.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:fluffychat/views/widgets/list_items/participant_list_item.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/views/ui/chat_details_ui.dart';
import 'package:fluffychat/views/widgets/matrix.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:matrix_link_text/link_text.dart';
import 'package:image_picker/image_picker.dart';
import '../utils/url_launcher.dart';
class ChatDetails extends StatefulWidget {
final String roomId;
class ChatDetailsView extends StatelessWidget {
final ChatDetailsController controller;
const ChatDetailsView(this.controller, {Key key}) : super(key: key);
const ChatDetails(this.roomId);
@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),
),
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 setCanonicalAliasAction(context) async {
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(
hintText: '#localpart:domain',
initialText: L10n.of(context).alias.toLowerCase(),
)
],
);
if (input == null) return;
final room = Matrix.of(context).client.getRoomById(widget.roomId);
final domain = room.client.userID.domain;
final canonicalAlias = '%23' + input.single + '%3A' + domain;
final aliasEvent = room.getState('m.room.aliases', domain);
final aliases =
aliasEvent != null ? aliasEvent.content['aliases'] ?? [] : [];
if (aliases.indexWhere((s) => s == canonicalAlias) == -1) {
final newAliases = List<String>.from(aliases);
newAliases.add(canonicalAlias);
final response = await showFutureLoadingDialog(
context: context,
future: () => room.client.requestRoomAliasInformation(canonicalAlias),
);
if (response.error != null) {
final success = await showFutureLoadingDialog(
context: context,
future: () => room.client.createRoomAlias(canonicalAlias, room.id),
);
if (success.error != null) return;
}
}
await showFutureLoadingDialog(
context: context,
future: () => room.client.sendState(room.id, 'm.room.canonical_alias', {
'alias': input.single,
}),
);
}
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);
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.canSendEvent('m.room.canonical_alias') &&
room.joinRules == JoinRules.public)
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.link_outlined),
),
onTap: () =>
controller.setCanonicalAliasAction(context),
title: Text(L10n.of(context).setInvitationLink),
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.world_readable,
child: Text(HistoryVisibility.world_readable
.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.can_join,
child: Text(
GuestAccess.can_join.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,
),
),
),
),
);
});
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/views/ui/chat_encryption_settings_ui.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'widgets/dialogs/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);
}

236
lib/views/chat_list.dart Normal file
View file

@ -0,0 +1,236 @@
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/views/ui/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 '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;
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))) {
UrlLauncher(context, text).openMatrixToUrl();
return;
}
Matrix.of(context).shareContent = {
'msgtype': 'm.text',
'body': text,
};
}
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);
}
@override
void initState() {
_initReceiveSharingIntent();
super.initState();
}
@override
void dispose() {
_intentDataStreamSubscription?.cancel();
_intentFileStreamSubscription?.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.mentions_only
: 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.sendPresence(
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/views/ui/chat_permissions_settings_ui.dart';
import 'package:fluffychat/views/widgets/dialogs/permission_slider_dialog.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/views/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.sendState(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/views/ui/device_settings_ui.dart';
import 'package:fluffychat/views/widgets/dialogs/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.requestDevices();
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
.setDeviceMetadata(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,134 @@
import 'dart:async';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/ui/homeserver_picker_ui.dart';
import 'package:fluffychat/views/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: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:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:url_launcher/url_launcher.dart';
import '../utils/localized_exception_extension.dart';
import 'package:universal_html/prefer_universal/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 _processIncomingSharedText(String text) async {
if (text == null || !text.startsWith(AppConfig.appOpenUrlScheme)) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
final token = Uri.parse(text).queryParameters['loginToken'];
_loginWithToken(token);
}
void _initReceiveSharingContent() {
if (!PlatformInfos.isMobile) return;
_intentDataStreamSubscription =
ReceiveSharingIntent.getTextStream().listen(_processIncomingSharedText);
ReceiveSharingIntent.getInitialText().then(_processIncomingSharedText);
}
@override
void initState() {
super.initState();
_initReceiveSharingContent();
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.requestLoginTypes();
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/views/ui/image_viewer_ui.dart';
import 'package:fluffychat/views/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/views/ui/invitation_selection_ui.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/views/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.searchUser(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);
}

44
lib/views/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/views/ui/new_group_ui.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/views/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/views/ui/new_private_chat_ui.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/views/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.searchUser(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);
}

71
lib/views/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/views/ui/sign_up_ui.dart';
import 'package:fluffychat/views/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.usernameAvailable(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,131 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/ui/sign_up_password_ui.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:url_launcher/url_launcher.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();
String passwordError;
String _lastAuthWebViewStage;
bool loading = false;
bool showPassword = true;
void toggleShowPassword() => setState(() => showPassword = !showPassword);
void signUpAction({AuthenticationData auth}) async {
final matrix = Matrix.of(context);
if (passwordController.text.isEmpty) {
setState(() => passwordError = L10n.of(context).pleaseEnterYourPassword);
} else {
setState(() => passwordError = null);
}
if (passwordController.text.isEmpty) {
return;
}
try {
setState(() => loading = true);
final waitForLogin = matrix.client.onLoginStateChanged.stream.first;
await matrix.client.register(
username: widget.username,
password: passwordController.text,
initialDeviceDisplayName: PlatformInfos.clientName,
auth: auth,
);
await waitForLogin;
} on MatrixException catch (exception) {
if (exception.requireAdditionalAuthentication) {
final stages = exception.authenticationFlows.first.stages;
final currentStage = exception.completedAuthenticationFlows == null
? stages.first
: stages.firstWhere((stage) =>
!exception.completedAuthenticationFlows.contains(stage) ??
true);
if (currentStage == 'm.login.dummy') {
signUpAction(
auth: AuthenticationData(
type: currentStage,
session: exception.session,
),
);
} else {
if (_lastAuthWebViewStage == currentStage) {
_lastAuthWebViewStage = null;
setState(
() => passwordError = L10n.of(context).oopsSomethingWentWrong);
return setState(() => loading = false);
}
_lastAuthWebViewStage = currentStage;
await launch(
Matrix.of(context).client.homeserver.toString() +
'/_matrix/client/r0/auth/$currentStage/fallback/web?session=${exception.session}',
);
if (OkCancelResult.ok ==
await showOkCancelAlertDialog(
message: L10n.of(context).pleaseFollowInstructionsOnWeb,
context: context,
okLabel: L10n.of(context).next,
cancelLabel: L10n.of(context).cancel,
useRootNavigator: false,
)) {
signUpAction(
auth: AuthenticationData(session: exception.session),
);
} else {
setState(() {
loading = false;
passwordError = null;
});
}
return;
}
} else {
setState(() => passwordError = exception.errorMessage);
return setState(() => loading = false);
}
} catch (exception) {
setState(() => passwordError = exception.toString());
return setState(() => loading = false);
}
await matrix.client.onLoginStateChanged.stream
.firstWhere((l) => l == LoginState.logged);
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

@ -1,13 +1,13 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/controllers/archive_controller.dart';
import 'package:fluffychat/views/archive.dart';
import 'package:fluffychat/views/widgets/list_items/chat_list_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ArchiveView extends StatelessWidget {
class ArchiveUI extends StatelessWidget {
final ArchiveController controller;
const ArchiveView(this.controller, {Key key}) : super(key: key);
const ArchiveUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -0,0 +1,369 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/views/chat_details.dart';
import 'package:fluffychat/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/widgets/chat_settings_popup_menu.dart';
import 'package:fluffychat/views/widgets/content_banner.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:fluffychat/views/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.canSendEvent('m.room.canonical_alias') &&
room.joinRules == JoinRules.public)
ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Colors.grey,
child: Icon(Icons.link_outlined),
),
onTap: () =>
controller.setCanonicalAliasAction(context),
title: Text(L10n.of(context).setInvitationLink),
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.world_readable,
child: Text(HistoryVisibility.world_readable
.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.can_join,
child: Text(
GuestAccess.can_join.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

@ -1,17 +1,16 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/controllers/chat_encryption_settings_controller.dart';
import 'package:fluffychat/views/chat_encryption_settings.dart';
import 'package:fluffychat/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../utils/device_extension.dart';
import '../../utils/device_extension.dart';
class ChatEncryptionSettingsView extends StatelessWidget {
class ChatEncryptionSettingsUI extends StatelessWidget {
final ChatEncryptionSettingsController controller;
const ChatEncryptionSettingsView(this.controller, {Key key})
: super(key: key);
const ChatEncryptionSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,19 +1,19 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/controllers/chat_list_controller.dart';
import 'package:fluffychat/views/chat_list.dart';
import 'package:fluffychat/views/widgets/connection_status_header.dart';
import 'package:fluffychat/views/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 '../widgets/matrix.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ChatListView extends StatelessWidget {
class ChatListUI extends StatelessWidget {
final ChatListController controller;
const ChatListView(this.controller, {Key key}) : super(key: key);
const ChatListUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,4 +1,4 @@
import 'package:fluffychat/controllers/chat_permissions_settings_controller.dart';
import 'package:fluffychat/views/chat_permissions_settings.dart';
import 'package:fluffychat/views/widgets/list_items/permission_list_tile.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
@ -7,11 +7,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:famedlysdk/famedlysdk.dart';
class ChatPermissionsSettingsView extends StatelessWidget {
class ChatPermissionsSettingsUI extends StatelessWidget {
final ChatPermissionsSettingsController controller;
const ChatPermissionsSettingsView(this.controller, {Key key})
: super(key: key);
const ChatPermissionsSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

747
lib/views/ui/chat_ui.dart Normal file
View file

@ -0,0 +1,747 @@
import 'dart:math';
import 'dart:ui';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/chat.dart';
import 'package:fluffychat/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/chat_settings_popup_menu.dart';
import 'package:fluffychat/views/widgets/connection_status_header.dart';
import 'package:fluffychat/views/widgets/input_bar.dart';
import 'package:fluffychat/views/widgets/unread_badge_back_button.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/views/widgets/encryption_button.dart';
import 'package:fluffychat/views/widgets/list_items/message.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:fluffychat/views/widgets/reply_content.dart';
import 'package:fluffychat/views/widgets/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),
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 (!snapshot.hasData) {
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,
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,
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),
),
);
},
),
),
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,
),
controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join
? Container(
decoration: BoxDecoration(
color: Theme.of(context).backgroundColor,
),
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>[
if (controller.inputText.isEmpty)
Container(
height: 56,
alignment: Alignment.center,
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,
),
),
],
),
)
: Container(),
],
),
),
],
),
);
}
}
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

@ -1,14 +1,14 @@
import 'package:fluffychat/controllers/device_settings_controller.dart';
import 'package:fluffychat/views/device_settings.dart';
import 'package:fluffychat/views/widgets/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';
import '../widgets/list_items/user_device_list_item.dart';
class DevicesSettingsView extends StatelessWidget {
class DevicesSettingsUI extends StatelessWidget {
final DevicesSettingsController controller;
const DevicesSettingsView(this.controller, {Key key}) : super(key: key);
const DevicesSettingsUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,4 +1,4 @@
import '../controllers/homeserver_picker_controller.dart';
import '../homeserver_picker.dart';
import 'package:fluffychat/views/widgets/default_app_bar_search_field.dart';
import 'package:fluffychat/views/widgets/fluffy_banner.dart';
import 'package:fluffychat/config/app_config.dart';
@ -10,13 +10,10 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class HomeserverPickerView extends StatelessWidget {
class HomeserverPickerUI extends StatelessWidget {
final HomeserverPickerController controller;
const HomeserverPickerView(
this.controller, {
Key key,
}) : super(key: key);
const HomeserverPickerUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,12 +1,12 @@
import '../controllers/image_viewer_controller.dart';
import '../image_viewer.dart';
import 'package:fluffychat/views/widgets/image_bubble.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ImageViewerView extends StatelessWidget {
class ImageViewerUI extends StatelessWidget {
final ImageViewerController controller;
const ImageViewerView(this.controller, {Key key}) : super(key: key);
const ImageViewerUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,4 +1,4 @@
import 'package:fluffychat/controllers/invitation_selection_controller.dart';
import 'package:fluffychat/views/invitation_selection.dart';
import 'package:fluffychat/views/widgets/default_app_bar_search_field.dart';
import 'package:famedlysdk/famedlysdk.dart';
@ -8,13 +8,10 @@ import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class InvitationSelectionView extends StatelessWidget {
class InvitationSelectionUI extends StatelessWidget {
final InvitationSelectionController controller;
const InvitationSelectionView(
this.controller, {
Key key,
}) : super(key: key);
const InvitationSelectionUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -9,7 +9,7 @@ import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../utils/platform_infos.dart';
import '../../utils/platform_infos.dart';
import 'package:email_validator/email_validator.dart';
class Login extends StatefulWidget {

View file

@ -1,15 +1,12 @@
import 'package:fluffychat/controllers/new_group_controller.dart';
import 'package:fluffychat/views/new_group.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class NewGroupView extends StatelessWidget {
class NewGroupUI extends StatelessWidget {
final NewGroupController controller;
const NewGroupView(
this.controller, {
Key key,
}) : super(key: key);
const NewGroupUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,5 +1,5 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/controllers/new_private_chat_controller.dart';
import 'package:fluffychat/views/new_private_chat.dart';
import 'package:fluffychat/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/contacts_list.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
@ -8,10 +8,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:famedlysdk/famedlysdk.dart';
class NewPrivateChatView extends StatelessWidget {
class NewPrivateChatUI extends StatelessWidget {
final NewPrivateChatController controller;
const NewPrivateChatView(this.controller, {Key key}) : super(key: key);
const NewPrivateChatUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -11,7 +11,7 @@ import 'package:fluffychat/views/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 '../../utils/localized_exception_extension.dart';
class SearchView extends StatefulWidget {
final String alias;

View file

@ -13,7 +13,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:image_picker/image_picker.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import '../views/widgets/matrix.dart';
import '../widgets/matrix.dart';
class EmotesSettings extends StatefulWidget {
final Room room;

View file

@ -5,7 +5,7 @@ import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../views/widgets/matrix.dart';
import '../widgets/matrix.dart';
class SettingsIgnoreList extends StatefulWidget {
final String initialUserId;

View file

@ -9,9 +9,9 @@ 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 '../utils/localized_exception_extension.dart';
import '../../utils/localized_exception_extension.dart';
import '../views/widgets/matrix.dart';
import '../widgets/matrix.dart';
class NotificationSettingsItem {
final PushRuleKind type;

View file

@ -7,8 +7,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:image_picker/image_picker.dart';
import '../config/app_config.dart';
import '../views/widgets/matrix.dart';
import '../../config/app_config.dart';
import '../widgets/matrix.dart';
class SettingsStyle extends StatefulWidget {
@override

View file

@ -21,11 +21,11 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:image_picker/image_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import '../views/widgets/content_banner.dart';
import '../widgets/content_banner.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import '../views/widgets/matrix.dart';
import '../config/app_config.dart';
import '../config/setting_keys.dart';
import '../widgets/matrix.dart';
import '../../config/app_config.dart';
import '../../config/setting_keys.dart';
class Settings extends StatefulWidget {
@override

View file

@ -1,16 +1,13 @@
import 'package:fluffychat/controllers/sign_up_password_controller.dart';
import 'package:fluffychat/views/sign_up_password.dart';
import 'package:fluffychat/views/widgets/one_page_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class SignUpPasswordView extends StatelessWidget {
class SignUpPasswordUI extends StatelessWidget {
final SignUpPasswordController controller;
const SignUpPasswordView(
this.controller, {
Key key,
}) : super(key: key);
const SignUpPasswordUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,5 +1,5 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/controllers/sign_up_controller.dart';
import 'package:fluffychat/views/sign_up.dart';
import 'package:fluffychat/views/widgets/fluffy_banner.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
@ -8,13 +8,10 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class SignUpView extends StatelessWidget {
class SignUpUI extends StatelessWidget {
final SignUpController controller;
const SignUpView(
this.controller, {
Key key,
}) : super(key: key);
const SignUpUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -1,5 +1,5 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/controllers/image_viewer_controller.dart';
import 'package:fluffychat/views/image_viewer.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';

View file

@ -19,7 +19,7 @@ import 'package:provider/provider.dart';
import 'package:universal_html/prefer_universal/html.dart' as html;
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
/*import 'package:fluffychat/views/chat.dart';
/*import 'package:fluffychat/views/chat_ui.dart';
import 'package:fluffychat/app_config.dart';
import 'package:dbus/dbus.dart';
import 'package:desktop_notifications/desktop_notifications.dart';*/