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

View file

@ -0,0 +1,45 @@
import 'package:famedlysdk/famedlysdk.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 ArchiveUI extends StatelessWidget {
final ArchiveController controller;
const ArchiveUI(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).archive),
),
body: FutureBuilder<List<Room>>(
future: controller.getArchive(context),
builder: (BuildContext context, snapshot) {
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;
return ListView.builder(
itemCount: controller.archive.length,
itemBuilder: (BuildContext context, int i) => ChatListItem(
controller.archive[i],
onForget: controller.forgetAction,
),
);
}
},
),
);
}
}

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

@ -0,0 +1,199 @@
import 'package:famedlysdk/famedlysdk.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';
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/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 '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/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';
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.requestServerCapabilities(),
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),
);
},
),
},
],
),
],
);
},
),
),
);
}
}

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

@ -0,0 +1,97 @@
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';
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,108 @@
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';
import 'package:fluffychat/views/widgets/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,
),
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/views/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/views/invitation_selection.dart';
import 'package:fluffychat/views/widgets/default_app_bar_search_field.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:fluffychat/views/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),
),
);
},
),
),
);
}
}

285
lib/views/ui/login_ui.dart Normal file
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/views/widgets/one_page_card.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/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/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 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/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';
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 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),
),
],
),
),
);
}
}

399
lib/views/ui/search_ui.dart Normal file
View file

@ -0,0 +1,399 @@
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/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/contacts_list.dart';
import 'package:fluffychat/views/widgets/default_app_bar_search_field.dart';
import 'package:fluffychat/views/widgets/list_items/chat_list_item.dart';
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';
class SearchView extends StatefulWidget {
final String alias;
const SearchView({Key key, this.alias}) : super(key: key);
@override
_SearchViewState createState() => _SearchViewState();
}
class _SearchViewState extends State<SearchView> {
final TextEditingController _controller = TextEditingController();
Future<PublicRoomsResponse> _publicRoomsResponse;
String _lastServer;
Timer _coolDown;
String _genericSearchTerm;
void _search(BuildContext context, 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
.joinRoomOrAlias(alias?.isNotEmpty ?? false ? alias : roomId);
await Matrix.of(context)
.client
.onRoomUpdate
.stream
.firstWhere((r) => r.id == newRoomId);
return newRoomId;
}
void _joinGroupAction(BuildContext context, 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(BuildContext context) 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.searchUser(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) {
final server = _genericSearchTerm?.isValidMatrixId ?? false
? _genericSearchTerm.domain
: _server;
if (_lastServer != server) {
_lastServer = server;
_publicRoomsResponse = null;
}
_publicRoomsResponse ??= Matrix.of(context)
.client
.searchPublicRooms(
server: server,
genericSearchTerm: _genericSearchTerm,
)
.catchError((error) {
if (widget.alias == null) {
throw error;
}
return PublicRoomsResponse.fromJson({
'chunk': [],
});
}).then((PublicRoomsResponse res) {
if (widget.alias != null &&
!res.chunk.any((room) =>
(room.aliases?.contains(widget.alias) ?? false) ||
room.canonicalAlias == widget.alias)) {
// we have to tack on the original alias
res.chunk.add(PublicRoom.fromJson(<String, dynamic>{
'aliases': [widget.alias],
'name': 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.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,
suffix: Icon(Icons.search_outlined),
onChanged: (t) => _search(context, t),
),
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: () => _setServer(context),
),
FutureBuilder<PublicRoomsResponse>(
future: _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: () => _joinGroupAction(
context,
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]),
),
foundProfiles.isNotEmpty
? ListView.builder(
itemCount: foundProfiles.length,
itemBuilder: (BuildContext context, int i) {
final foundProfile = 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),
],
),
),
);
}
}

View file

@ -0,0 +1,179 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/widgets/max_width_body.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 Settings3Pid extends StatefulWidget {
static int sendAttempt = 0;
@override
_Settings3PidState createState() => _Settings3PidState();
}
class _Settings3PidState extends State<Settings3Pid> {
void _add3PidAction(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.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.addThirdPartyIdentifier(
clientSecret,
response.result.sid,
auth: auth,
),
),
);
if (success.error != null) return;
setState(() => _request = null);
}
Future<List<ThirdPartyIdentifier>> _request;
void _delete3Pid(
BuildContext context, 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.deleteThirdPartyIdentifier(
identifier.address,
identifier.medium,
));
if (success.error != null) return;
setState(() => _request = null);
}
@override
Widget build(BuildContext context) {
_request ??= Matrix.of(context).client.requestThirdPartyIdentifiers();
return Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).passwordRecovery),
actions: [
IconButton(
icon: Icon(Icons.add_outlined),
onPressed: () => _add3PidAction(context),
tooltip: L10n.of(context).addEmail,
)
],
),
body: MaxWidthBody(
child: FutureBuilder<List<ThirdPartyIdentifier>>(
future: _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: () => _delete3Pid(context, 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,494 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
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 '../widgets/matrix.dart';
class EmotesSettings extends StatefulWidget {
final Room room;
final String stateKey;
EmotesSettings({this.room, this.stateKey});
@override
_EmotesSettingsState createState() => _EmotesSettingsState();
}
class _EmoteEntry {
String emote;
String mxc;
_EmoteEntry({this.emote, this.mxc});
String get emoteClean => emote.substring(1, emote.length - 1);
}
class _EmotesSettingsState 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.sendState(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(BuildContext context, 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),
);
}
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'));
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
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 = 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 Scaffold(
appBar: AppBar(
leading: BackButton(),
title: Text(L10n.of(context).emoteSettings),
),
floatingActionButton: showSave
? FloatingActionButton(
onPressed: () async {
await _save(context);
setState(() {
showSave = false;
});
},
child: Icon(Icons.save_outlined, color: Colors.white),
)
: null,
body: MaxWidthBody(
child: StreamBuilder(
stream: widget.room?.onUpdate?.stream,
builder: (context, snapshot) {
return Column(
children: <Widget>[
if (!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: 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(newMxcController),
trailing: InkWell(
onTap: () 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;
});
},
child: Icon(
Icons.add_outlined,
color: Colors.green,
size: 32.0,
),
),
),
),
if (widget.room != null)
ListTile(
title: Text(L10n.of(context).enableEmotesGlobally),
trailing: Switch(
value: isGloballyActive(client),
onChanged: (bool newValue) async {
await _setIsGloballyActive(context, newValue);
setState(() => null);
},
),
),
if (!readonly || widget.room != null)
Divider(
height: 2,
thickness: 2,
color: Theme.of(context).primaryColor,
),
Expanded(
child: 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: emotes.length + 1,
itemBuilder: (BuildContext context, int i) {
if (i >= emotes.length) {
return Container(height: 70);
}
final emote = emotes[i];
final controller = TextEditingController();
controller.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: readonly,
controller: controller,
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) {
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;
});
},
),
),
title: _EmoteImage(emote.mxc),
trailing: readonly
? null
: InkWell(
onTap: () => setState(() {
emotes.removeWhere(
(e) => e.emote == emote.emote);
showSave = true;
}),
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,
fit: BoxFit.contain,
width: size,
height: size,
);
}
}
class _EmoteImagePicker extends StatefulWidget {
final TextEditingController controller;
_EmoteImagePicker(this.controller);
@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 {
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.upload(file.bytes, file.name),
);
if (uploadResp.error == null) {
setState(() {
widget.controller.text = uploadResp.result;
});
}
},
child: Text(L10n.of(context).pickImage),
);
} else {
return _EmoteImage(widget.controller.text);
}
}
}

View file

@ -0,0 +1,119 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/widgets/avatar.dart';
import 'package:fluffychat/views/widgets/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 '../widgets/matrix.dart';
class SettingsIgnoreList extends StatefulWidget {
final String initialUserId;
SettingsIgnoreList({Key key, this.initialUserId}) : super(key: key);
@override
_SettingsIgnoreListState createState() => _SettingsIgnoreListState();
}
class _SettingsIgnoreListState 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) {
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,
autocorrect: false,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _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: () => _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,60 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class MultipleEmotesSettings extends StatelessWidget {
final String roomId;
MultipleEmotesSettings(this.roomId, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(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,214 @@
import 'dart:io';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:flutter/foundation.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 '../../utils/localized_exception_extension.dart';
import '../widgets/matrix.dart';
class NotificationSettingsItem {
final PushRuleKind type;
final String key;
final String Function(BuildContext) title;
NotificationSettingsItem(this.type, this.key, this.title);
}
class SettingsNotifications extends StatelessWidget {
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,
),
];
void _openAndroidNotificationSettingsAction() async {
await NotificationSetting.configureChannel(
NotificationDetails(
android: AndroidNotificationDetails(
AppConfig.pushNotificationsChannelId,
AppConfig.pushNotificationsChannelName,
AppConfig.pushNotificationsChannelDescription,
),
),
);
return NotificationSetting.open();
}
bool _getNotificationSetting(
BuildContext context, 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(
BuildContext context, NotificationSettingsItem item, bool enabled) {
showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.enablePushRule(
'global',
item.type,
item.key,
enabled,
),
);
}
@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: () => _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 items)
SwitchListTile(
value: _getNotificationSetting(context, item) ?? true,
title: Text(item.title(context)),
onChanged: (bool enabled) =>
_setNotificationSetting(context, 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.requestPushers(),
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,161 @@
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/views/widgets/max_width_body.dart';
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 '../widgets/matrix.dart';
class SettingsStyle extends StatefulWidget {
@override
_SettingsStyleState createState() => _SettingsStyleState();
}
class _SettingsStyleState extends State<SettingsStyle> {
void setWallpaperAction(BuildContext context) 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(BuildContext context) async {
Matrix.of(context).wallpaper = null;
await Matrix.of(context).store.deleteItem(SettingKeys.wallpaper);
setState(() => null);
}
AdaptiveThemeMode _currentTheme;
void _switchTheme(AdaptiveThemeMode newTheme, BuildContext context) {
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);
}
@override
Widget build(BuildContext context) {
_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: _currentTheme,
value: AdaptiveThemeMode.system,
title: Text(L10n.of(context).systemTheme),
onChanged: (t) => _switchTheme(t, context),
),
RadioListTile<AdaptiveThemeMode>(
groupValue: _currentTheme,
value: AdaptiveThemeMode.light,
title: Text(L10n.of(context).lightTheme),
onChanged: (t) => _switchTheme(t, context),
),
RadioListTile<AdaptiveThemeMode>(
groupValue: _currentTheme,
value: AdaptiveThemeMode.dark,
title: Text(L10n.of(context).darkTheme),
onChanged: (t) => _switchTheme(t, context),
),
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: () => deleteWallpaperAction(context),
),
Builder(builder: (context) {
return ListTile(
title: Text(L10n.of(context).changeWallpaper),
trailing: Icon(Icons.wallpaper_outlined),
onTap: () => setWallpaperAction(context),
);
}),
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: (d) {
setState(() => AppConfig.fontSizeFactor = d);
Matrix.of(context).store.setItem(
SettingKeys.fontSizeFactor,
AppConfig.fontSizeFactor.toString(),
);
},
),
],
),
),
);
}
}

View file

@ -0,0 +1,622 @@
import 'dart:async';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/views/widgets/dialogs/bootstrap_dialog.dart';
import 'package:fluffychat/views/widgets/sentry_switch_list_tile.dart';
import 'package:fluffychat/views/widgets/settings_switch_list_tile.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.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: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_screen_lock/lock_screen.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:image_picker/image_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import '../widgets/content_banner.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import '../widgets/matrix.dart';
import '../../config/app_config.dart';
import '../../config/setting_keys.dart';
class Settings extends StatefulWidget {
@override
_SettingsState createState() => _SettingsState();
}
class _SettingsState extends State<Settings> {
Future<dynamic> profileFuture;
dynamic profile;
Future<bool> crossSigningCachedFuture;
bool crossSigningCached;
Future<bool> megolmBackupCachedFuture;
bool megolmBackupCached;
void logoutAction(BuildContext context) 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(BuildContext context) 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(BuildContext context) 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(BuildContext context) 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(BuildContext context) 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(BuildContext context) 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(BuildContext context) 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(BuildContext context) async {
final currentLock =
await FlutterSecureStorage().read(key: SettingKeys.appLockKey);
if (currentLock?.isNotEmpty ?? false) {
var unlocked = false;
await showLockScreen(
context: context,
correctString: currentLock,
onUnlocked: () => unlocked = true,
canBiometric: true,
);
if (unlocked != true) return;
}
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();
}
}
}
@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 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,
style: TextStyle(
color: Theme.of(context)
.appBarTheme
.textTheme
.headline6
.color)),
actions: [
FutureBuilder(
future: 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: () async {
await BootstrapDialog(
client: Matrix.of(context).client,
).show(context);
AdaptivePageLayout.of(context).popUntilIsFirst();
},
);
}),
],
backgroundColor: Theme.of(context).appBarTheme.color,
flexibleSpace: FlexibleSpaceBar(
background: ContentBanner(profile?.avatarUrl,
onEdit: () => setAvatarAction(context)),
),
),
],
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(profile?.displayname ?? client.userID.localpart),
onTap: () => setDisplaynameAction(context),
),
ListTile(
trailing: Icon(Icons.phone_outlined),
title: Text(L10n.of(context).editJitsiInstance),
subtitle: Text(AppConfig.jitsiInstance),
onTap: () => setJitsiInstanceAction(context),
),
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: () => _changePasswordAccountAction(context),
),
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: () => logoutAction(context),
),
ListTile(
trailing: Icon(Icons.delete_forever_outlined),
title: Text(
L10n.of(context).deleteAccount,
style: TextStyle(color: Colors.red),
),
onTap: () => _deleteAccountAction(context),
),
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: () => _setAppLockAction(context),
),
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: () async {
if (await 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);
},
),
},
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,72 @@
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 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),
),
),
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/views/sign_up.dart';
import 'package:fluffychat/views/widgets/fluffy_banner.dart';
import 'package:fluffychat/views/widgets/matrix.dart';
import 'package:fluffychat/views/widgets/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,
),
),
),
),
]),
),
);
}
}