feat: new design

This commit is contained in:
Christian Pauly 2021-02-02 09:00:06 +01:00
parent b079e2bcf5
commit 33dd1d2371
19 changed files with 3428 additions and 696 deletions

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ class DefaultAppBarSearchField extends StatefulWidget {
final String hintText; final String hintText;
final EdgeInsets padding; final EdgeInsets padding;
final bool readOnly; final bool readOnly;
final Widget prefixIcon;
const DefaultAppBarSearchField({ const DefaultAppBarSearchField({
Key key, Key key,
@ -20,6 +21,7 @@ class DefaultAppBarSearchField extends StatefulWidget {
this.hintText, this.hintText,
this.padding, this.padding,
this.readOnly = false, this.readOnly = false,
this.prefixIcon,
}) : super(key: key); }) : super(key: key);
@override @override
@ -73,12 +75,18 @@ class _DefaultAppBarSearchFieldState extends State<DefaultAppBarSearchField> {
readOnly: widget.readOnly, readOnly: widget.readOnly,
decoration: InputDecoration( decoration: InputDecoration(
prefixText: widget.prefixText, prefixText: widget.prefixText,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
BorderSide(color: Theme.of(context).secondaryHeaderColor),
),
contentPadding: EdgeInsets.only( contentPadding: EdgeInsets.only(
top: 8, top: 8,
bottom: 8, bottom: 8,
left: 16, left: 16,
), ),
hintText: widget.hintText, hintText: widget.hintText,
prefixIcon: widget.prefixIcon,
suffixIcon: !widget.readOnly && suffixIcon: !widget.readOnly &&
(_focusNode.hasFocus || (_focusNode.hasFocus ||
(widget.suffix == null && (widget.suffix == null &&

View File

@ -1,94 +0,0 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'matrix.dart';
class DefaultDrawer extends StatelessWidget {
void _drawerTapAction(BuildContext context, String route) {
Navigator.of(context).pop();
AdaptivePageLayout.of(context).pushNamedAndRemoveUntilIsFirst(route);
}
void _setStatus(BuildContext context) async {
final client = Matrix.of(context).client;
final input = await showTextInputDialog(
title: L10n.of(context).setStatus,
context: context,
textFields: [
DialogTextField(
hintText: L10n.of(context).statusExampleMessage,
)
],
);
if (input == null || input.single.isEmpty) return;
await showFutureLoadingDialog(
context: context,
future: () => client.sendPresence(
client.userID,
PresenceType.online,
statusMsg: input.single,
),
);
Navigator.of(context).pop();
return;
}
@override
Widget build(BuildContext context) {
return Drawer(
child: SafeArea(
child: ListView(
padding: EdgeInsets.zero,
children: <Widget>[
ListTile(
leading: Icon(Icons.edit_outlined),
title: Text(L10n.of(context).setStatus),
onTap: () => _setStatus(context),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.people_outline),
title: Text(L10n.of(context).createNewGroup),
onTap: () => _drawerTapAction(context, '/newgroup'),
),
ListTile(
leading: Icon(Icons.person_add_outlined),
title: Text(L10n.of(context).newPrivateChat),
onTap: () => _drawerTapAction(context, '/newprivatechat'),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.archive_outlined),
title: Text(L10n.of(context).archive),
onTap: () => _drawerTapAction(
context,
'/archive',
),
),
ListTile(
leading: Icon(Icons.group_work_outlined),
title: Text(L10n.of(context).discoverGroups),
onTap: () => _drawerTapAction(
context,
'/discover',
),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.settings_outlined),
title: Text(L10n.of(context).settings),
onTap: () => _drawerTapAction(
context,
'/settings',
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,130 @@
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:fluffychat/components/avatar.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/status.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/string_color.dart';
import '../../utils/date_time_extension.dart';
import '../matrix.dart';
class StatusListTile extends StatelessWidget {
final Status status;
const StatusListTile({Key key, @required this.status}) : super(key: key);
@override
Widget build(BuildContext context) {
final text = status.message;
final isImage = text.startsWith('mxc://') && text.split(' ').length == 1;
return FutureBuilder<Profile>(
future: Matrix.of(context).client.getProfileFromUserId(status.senderId),
builder: (context, snapshot) {
final displayname =
snapshot.data?.displayname ?? status.senderId.localpart;
final avatarUrl = snapshot.data?.avatarUrl;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Avatar(avatarUrl, displayname),
title: Text(
displayname,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(status.dateTime.localizedTime(context),
style: TextStyle(fontSize: 14)),
trailing: PopupMenuButton(
onSelected: (_) => AdaptivePageLayout.of(context).pushNamed(
'/settings/ignore',
arguments: status.senderId),
itemBuilder: (_) => [
PopupMenuItem(
child: Text(L10n.of(context).ignore),
value: 'ignore',
),
],
),
),
isImage
? CachedNetworkImage(
imageUrl: Uri.parse(text).getThumbnail(
Matrix.of(context).client,
width: 360,
height: 360,
method: ThumbnailMethod.scale,
),
fit: BoxFit.cover,
width: double.infinity,
)
: Container(
height: 256,
color: text.color,
alignment: Alignment.center,
child: SingleChildScrollView(
padding: EdgeInsets.all(12),
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 24,
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 12.0, left: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(CupertinoIcons.chat_bubble),
onPressed: () async {
final result = await showFutureLoadingDialog(
context: context,
future: () => User(
status.senderId,
room:
Room(id: '', client: Matrix.of(context).client),
).startDirectChat(),
);
if (result.error == null) {
await AdaptivePageLayout.of(context)
.pushNamed('/rooms/${result.result}');
}
},
),
IconButton(
icon: Icon(Icons.ios_share),
onPressed: () => AdaptivePageLayout.of(context)
.pushNamed('/newstatus', arguments: status.message),
),
IconButton(
icon: Icon(Icons.share_outlined),
onPressed: () => FluffyShare.share(
'$displayname: ${status.message}',
context,
),
),
IconButton(
icon: Icon(Icons.delete_outlined),
onPressed: () => showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context)
.removeStatusOfUser(status.senderId),
),
),
],
),
),
],
);
});
}
}

View File

@ -10,6 +10,7 @@ import 'package:fluffychat/utils/firebase_controller.dart';
import 'package:fluffychat/utils/matrix_locals.dart'; import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/sentry_controller.dart'; import 'package:fluffychat/utils/sentry_controller.dart';
import 'package:fluffychat/utils/status.dart';
import 'package:flushbar/flushbar.dart'; import 'package:flushbar/flushbar.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -126,6 +127,7 @@ class MatrixState extends State<Matrix> {
StreamSubscription onKeyVerificationRequestSub; StreamSubscription onKeyVerificationRequestSub;
StreamSubscription onJitsiCallSub; StreamSubscription onJitsiCallSub;
StreamSubscription onNotification; StreamSubscription onNotification;
StreamSubscription<Presence> onPresence;
StreamSubscription<LoginState> onLoginStateChanged; StreamSubscription<LoginState> onLoginStateChanged;
StreamSubscription<UiaRequest> onUiaRequest; StreamSubscription<UiaRequest> onUiaRequest;
StreamSubscription<html.Event> onFocusSub; StreamSubscription<html.Event> onFocusSub;
@ -288,6 +290,10 @@ class MatrixState extends State<Matrix> {
LoadingDialog.defaultBackLabel = L10n.of(context).close; LoadingDialog.defaultBackLabel = L10n.of(context).close;
LoadingDialog.defaultOnError = (Object e) => e.toLocalizedString(context); LoadingDialog.defaultOnError = (Object e) => e.toLocalizedString(context);
onPresence ??= client.onPresence.stream
.where((p) => p.presence?.statusMsg != null)
.listen(_onPresence);
onRoomKeyRequestSub ??= onRoomKeyRequestSub ??=
client.onRoomKeyRequest.stream.listen((RoomKeyRequest request) async { client.onRoomKeyRequest.stream.listen((RoomKeyRequest request) async {
final room = request.room; final room = request.room;
@ -395,6 +401,45 @@ class MatrixState extends State<Matrix> {
} }
} }
Map<String, Status> get statuses {
if (client.accountData.containsKey(Status.namespace)) {
try {
return client.accountData[Status.namespace].content
.map((k, v) => MapEntry(k, Status.fromJson(v)));
} catch (e, s) {
Logs()
.e('Unable to parse status account data. Clearing up now...', e, s);
client.setAccountData(client.userID, Status.namespace, {});
}
}
return {};
}
void _onPresence(Presence presence) async {
if (statuses[presence.senderId]?.message != presence.presence.statusMsg) {
Logs().v('Update status from ${presence.senderId}');
await client.setAccountData(
client.userID,
Status.namespace,
statuses.map((k, v) => MapEntry(k, v.toJson()))
..[presence.senderId] = Status(
presence.senderId,
presence.presence.statusMsg,
DateTime.now(),
),
);
}
}
Future<void> removeStatusOfUser(String userId) async {
await client.setAccountData(
client.userID,
Status.namespace,
statuses.map((k, v) => MapEntry(k, v.toJson()))..remove(userId),
);
return;
}
@override @override
void dispose() { void dispose() {
onRoomKeyRequestSub?.cancel(); onRoomKeyRequestSub?.cancel();
@ -403,6 +448,7 @@ class MatrixState extends State<Matrix> {
onNotification?.cancel(); onNotification?.cancel();
onFocusSub?.cancel(); onFocusSub?.cancel();
onBlurSub?.cancel(); onBlurSub?.cancel();
onPresence?.cancel();
super.dispose(); super.dispose();
} }

View File

@ -5,9 +5,8 @@ import 'package:fluffychat/views/archive.dart';
import 'package:fluffychat/views/chat.dart'; import 'package:fluffychat/views/chat.dart';
import 'package:fluffychat/views/chat_details.dart'; import 'package:fluffychat/views/chat_details.dart';
import 'package:fluffychat/views/chat_encryption_settings.dart'; import 'package:fluffychat/views/chat_encryption_settings.dart';
import 'package:fluffychat/views/chat_list.dart'; import 'package:fluffychat/views/home_view.dart';
import 'package:fluffychat/views/chat_permissions_settings.dart'; import 'package:fluffychat/views/chat_permissions_settings.dart';
import 'package:fluffychat/views/discover_view.dart';
import 'package:fluffychat/views/empty_page.dart'; import 'package:fluffychat/views/empty_page.dart';
import 'package:fluffychat/views/homeserver_picker.dart'; import 'package:fluffychat/views/homeserver_picker.dart';
import 'package:fluffychat/views/invitation_selection.dart'; import 'package:fluffychat/views/invitation_selection.dart';
@ -16,6 +15,7 @@ import 'package:fluffychat/views/log_view.dart';
import 'package:fluffychat/views/login.dart'; import 'package:fluffychat/views/login.dart';
import 'package:fluffychat/views/new_group.dart'; import 'package:fluffychat/views/new_group.dart';
import 'package:fluffychat/views/new_private_chat.dart'; import 'package:fluffychat/views/new_private_chat.dart';
import 'package:fluffychat/views/set_status_view.dart';
import 'package:fluffychat/views/settings.dart'; import 'package:fluffychat/views/settings.dart';
import 'package:fluffychat/views/settings_3pid.dart'; import 'package:fluffychat/views/settings_3pid.dart';
import 'package:fluffychat/views/settings_devices.dart'; import 'package:fluffychat/views/settings_devices.dart';
@ -64,14 +64,14 @@ class FluffyRoutes {
switch (parts[1]) { switch (parts[1]) {
case '': case '':
return ViewData( return ViewData(
mainView: (_) => ChatList(), mainView: (_) => HomeView(),
emptyView: (_) => EmptyPage(), emptyView: (_) => EmptyPage(),
); );
case 'rooms': case 'rooms':
final roomId = parts[2]; final roomId = parts[2];
if (parts.length == 3) { if (parts.length == 3) {
return ViewData( return ViewData(
leftView: (_) => ChatList(activeChat: roomId), leftView: (_) => HomeView(activeChat: roomId),
mainView: (_) => Chat(roomId), mainView: (_) => Chat(roomId),
); );
} else if (parts.length == 4) { } else if (parts.length == 4) {
@ -79,44 +79,44 @@ class FluffyRoutes {
switch (action) { switch (action) {
case 'details': case 'details':
return ViewData( return ViewData(
leftView: (_) => ChatList(activeChat: roomId), leftView: (_) => HomeView(activeChat: roomId),
mainView: (_) => Chat(roomId), mainView: (_) => Chat(roomId),
rightView: (_) => ChatDetails(roomId), rightView: (_) => ChatDetails(roomId),
); );
case 'encryption': case 'encryption':
return ViewData( return ViewData(
leftView: (_) => ChatList(activeChat: roomId), leftView: (_) => HomeView(activeChat: roomId),
mainView: (_) => Chat(roomId), mainView: (_) => Chat(roomId),
rightView: (_) => ChatEncryptionSettings(roomId), rightView: (_) => ChatEncryptionSettings(roomId),
); );
case 'permissions': case 'permissions':
return ViewData( return ViewData(
leftView: (_) => ChatList(activeChat: roomId), leftView: (_) => HomeView(activeChat: roomId),
mainView: (_) => Chat(roomId), mainView: (_) => Chat(roomId),
rightView: (_) => ChatPermissionsSettings(roomId), rightView: (_) => ChatPermissionsSettings(roomId),
); );
case 'invite': case 'invite':
return ViewData( return ViewData(
leftView: (_) => ChatList(activeChat: roomId), leftView: (_) => HomeView(activeChat: roomId),
mainView: (_) => Chat(roomId), mainView: (_) => Chat(roomId),
rightView: (_) => InvitationSelection(roomId), rightView: (_) => InvitationSelection(roomId),
); );
case 'emotes': case 'emotes':
return ViewData( return ViewData(
leftView: (_) => ChatList(activeChat: roomId), leftView: (_) => HomeView(activeChat: roomId),
mainView: (_) => Chat(roomId), mainView: (_) => Chat(roomId),
rightView: (_) => MultipleEmotesSettings(roomId), rightView: (_) => MultipleEmotesSettings(roomId),
); );
default: default:
return ViewData( return ViewData(
leftView: (_) => ChatList(activeChat: roomId), leftView: (_) => HomeView(activeChat: roomId),
mainView: (_) => Chat(roomId, mainView: (_) => Chat(roomId,
scrollToEventId: action.sigil == '\$' ? action : null), scrollToEventId: action.sigil == '\$' ? action : null),
); );
} }
} }
return ViewData( return ViewData(
mainView: (_) => ChatList(), mainView: (_) => HomeView(),
emptyView: (_) => EmptyPage(), emptyView: (_) => EmptyPage(),
); );
case 'archive': case 'archive':
@ -124,26 +124,25 @@ class FluffyRoutes {
mainView: (_) => Archive(), mainView: (_) => Archive(),
emptyView: (_) => EmptyPage(), emptyView: (_) => EmptyPage(),
); );
case 'discover':
return ViewData(
mainView: (_) =>
DiscoverPage(alias: parts.length == 3 ? parts[2] : null),
emptyView: (_) => EmptyPage(),
);
case 'logs': case 'logs':
return ViewData( return ViewData(
mainView: (_) => LogViewer(), mainView: (_) => LogViewer(),
); );
case 'newgroup': case 'newgroup':
return ViewData( return ViewData(
leftView: (_) => ChatList(), leftView: (_) => HomeView(),
mainView: (_) => NewGroup(), mainView: (_) => NewGroup(),
); );
case 'newprivatechat': case 'newprivatechat':
return ViewData( return ViewData(
leftView: (_) => ChatList(), leftView: (_) => HomeView(),
mainView: (_) => NewPrivateChat(), mainView: (_) => NewPrivateChat(),
); );
case 'newstatus':
return ViewData(
leftView: (_) => HomeView(),
mainView: (_) => SetStatusView(initialText: settings.arguments),
);
case 'settings': case 'settings':
if (parts.length == 3) { if (parts.length == 3) {
final action = parts[2]; final action = parts[2];
@ -169,7 +168,9 @@ class FluffyRoutes {
case 'ignore': case 'ignore':
return ViewData( return ViewData(
leftView: (_) => Settings(), leftView: (_) => Settings(),
mainView: (_) => SettingsIgnoreList(), mainView: (_) => SettingsIgnoreList(
initialUserId: settings.arguments,
),
); );
case 'notifications': case 'notifications':
return ViewData( return ViewData(

View File

@ -1332,8 +1332,36 @@
"username": {} "username": {}
} }
}, },
"ignore": "Ignore",
"@ignore": {
"type": "text",
"placeholders": {}
},
"status": "Status",
"@status": {
"type": "text",
"placeholders": {}
},
"messages": "Messages",
"@messages": {
"type": "text",
"placeholders": {}
},
"groups": "Groups",
"@groups": {
"type": "text",
"placeholders": {}
},
"discover": "Discover",
"@discover": {
"type": "text",
"placeholders": {}
},
"search": "Search",
"@search": {
"type": "text",
"placeholders": {}
},
"howOffensiveIsThisContent": "How offensive is this content?", "howOffensiveIsThisContent": "How offensive is this content?",
"@howOffensiveIsThisContent": { "@howOffensiveIsThisContent": {
"type": "text", "type": "text",

19
lib/utils/status.dart Normal file
View File

@ -0,0 +1,19 @@
class Status {
static const String namespace = 'im.fluffychat.statuses';
final String senderId;
final String message;
final DateTime dateTime;
Status(this.senderId, this.message, this.dateTime);
Status.fromJson(Map<String, dynamic> json)
: senderId = json['sender_id'],
message = json['message'],
dateTime = DateTime.fromMillisecondsSinceEpoch(json['date_time']);
Map<String, dynamic> toJson() => <String, dynamic>{
'sender_id': senderId,
'message': message,
'date_time': dateTime.millisecondsSinceEpoch,
};
}

View File

@ -1,341 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/components/connection_status_header.dart';
import 'package:fluffychat/components/default_app_bar_search_field.dart';
import 'package:fluffychat/components/default_drawer.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import '../components/list_items/chat_list_item.dart';
import '../components/matrix.dart';
import '../utils/matrix_file_extension.dart';
import '../utils/url_launcher.dart';
enum SelectMode { normal, share, select }
class ChatList extends StatefulWidget {
final String activeChat;
const ChatList({this.activeChat, Key key}) : super(key: key);
@override
_ChatListState createState() => _ChatListState();
}
class _ChatListState extends State<ChatList> {
bool get searchMode => searchController.text?.isNotEmpty ?? false;
final TextEditingController searchController = TextEditingController();
final _selectedRoomIds = <String>{};
final ScrollController _scrollController = ScrollController();
bool _scrolledToTop = true;
void _toggleSelection(String roomId) =>
setState(() => _selectedRoomIds.contains(roomId)
? _selectedRoomIds.remove(roomId)
: _selectedRoomIds.add(roomId));
Future<void> waitForFirstSync(BuildContext context) async {
var client = Matrix.of(context).client;
if (client.prevBatch?.isEmpty ?? true) {
await client.onFirstSync.stream.first;
}
return true;
}
@override
void initState() {
_scrollController.addListener(() async {
if (_scrollController.position.pixels > 0 && _scrolledToTop) {
setState(() => _scrolledToTop = false);
} else if (_scrollController.position.pixels == 0 && !_scrolledToTop) {
setState(() => _scrolledToTop = true);
}
});
_initReceiveSharingIntent();
super.initState();
}
StreamSubscription _intentDataStreamSubscription;
StreamSubscription _intentFileStreamSubscription;
void _processIncomingSharedFiles(List<SharedMediaFile> files) {
if (files?.isEmpty ?? true) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
final file = File(files.first.path);
Matrix.of(context).shareContent = {
'msgtype': 'chat.fluffy.shared_file',
'file': MatrixFile(
bytes: file.readAsBytesSync(),
name: file.path,
).detectFileType,
};
}
void _processIncomingSharedText(String text) {
if (text == null) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
if (text.toLowerCase().startsWith(AppConfig.inviteLinkPrefix) ||
(text.toLowerCase().startsWith(AppConfig.schemePrefix) &&
!RegExp(r'\s').hasMatch(text))) {
UrlLauncher(context, text).openMatrixToUrl();
return;
}
Matrix.of(context).shareContent = {
'msgtype': 'm.text',
'body': text,
};
}
void _initReceiveSharingIntent() {
if (!PlatformInfos.isMobile) return;
// For sharing images coming from outside the app while the app is in the memory
_intentFileStreamSubscription = ReceiveSharingIntent.getMediaStream()
.listen(_processIncomingSharedFiles, onError: print);
// For sharing images coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then(_processIncomingSharedFiles);
// For sharing or opening urls/text coming from outside the app while the app is in the memory
_intentDataStreamSubscription = ReceiveSharingIntent.getTextStream()
.listen(_processIncomingSharedText, onError: print);
// For sharing or opening urls/text coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialText().then(_processIncomingSharedText);
}
@override
void dispose() {
_intentDataStreamSubscription?.cancel();
_intentFileStreamSubscription?.cancel();
super.dispose();
}
Future<void> _toggleUnread(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(_selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setUnread(!room.isUnread),
);
}
Future<void> _toggleFavouriteRoom(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(_selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setFavourite(!room.isFavourite),
);
}
Future<void> _toggleMuted(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(_selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentions_only
: PushRuleState.notify),
);
}
Future<void> _archiveAction(BuildContext context) async {
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
) ==
OkCancelResult.ok;
if (!confirmed) return;
await showFutureLoadingDialog(
context: context,
future: () => _archiveSelectedRooms(context),
);
setState(() => null);
}
Future<void> _archiveSelectedRooms(BuildContext context) async {
final client = Matrix.of(context).client;
while (_selectedRoomIds.isNotEmpty) {
final roomId = _selectedRoomIds.first;
await client.getRoomById(roomId).leave();
_selectedRoomIds.remove(roomId);
}
}
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: Matrix.of(context).onShareContentChanged.stream,
builder: (context, snapshot) {
final selectMode = Matrix.of(context).shareContent == null
? _selectedRoomIds.isEmpty
? SelectMode.normal
: SelectMode.select
: SelectMode.share;
if (selectMode == SelectMode.share) {
_selectedRoomIds.clear();
}
Room selectedRoom;
if (_selectedRoomIds.length == 1) {
selectedRoom =
Matrix.of(context).client.getRoomById(_selectedRoomIds.single);
}
return Scaffold(
drawer: selectMode != SelectMode.normal ? null : DefaultDrawer(),
appBar: AppBar(
centerTitle: false,
elevation: _scrolledToTop ? 0 : null,
leading: selectMode == SelectMode.share
? IconButton(
icon: Icon(Icons.close),
onPressed: () => Matrix.of(context).shareContent = null,
)
: selectMode == SelectMode.select
? IconButton(
icon: Icon(Icons.close),
onPressed: () => setState(_selectedRoomIds.clear),
)
: null,
titleSpacing: 0,
actions: selectMode != SelectMode.select
? null
: [
if (_selectedRoomIds.length == 1)
IconButton(
tooltip: L10n.of(context).toggleUnread,
icon: Icon(selectedRoom.isUnread
? Icons.mark_chat_read_outlined
: Icons.mark_chat_unread_outlined),
onPressed: () => _toggleUnread(context),
),
if (_selectedRoomIds.length == 1)
IconButton(
tooltip: L10n.of(context).toggleFavorite,
icon: Icon(Icons.push_pin_outlined),
onPressed: () => _toggleFavouriteRoom(context),
),
if (_selectedRoomIds.length == 1)
IconButton(
icon: Icon(
selectedRoom.pushRuleState == PushRuleState.notify
? Icons.notifications_off_outlined
: Icons.notifications_outlined),
tooltip: L10n.of(context).toggleMuted,
onPressed: () => _toggleMuted(context),
),
IconButton(
icon: Icon(Icons.archive_outlined),
tooltip: L10n.of(context).archive,
onPressed: () => _archiveAction(context),
),
],
title: selectMode == SelectMode.share
? Text(L10n.of(context).share)
: selectMode == SelectMode.select
? Text(_selectedRoomIds.length.toString())
: DefaultAppBarSearchField(
searchController: searchController,
hintText: L10n.of(context).searchForAChat,
onChanged: (_) => setState(() => null),
suffix: Icon(Icons.search_outlined),
),
),
floatingActionButton:
AdaptivePageLayout.of(context).columnMode(context)
? null
: FloatingActionButton(
child: Icon(Icons.add_outlined),
onPressed: () => AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/newprivatechat'),
),
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: waitForFirstSync(context),
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
var rooms = List<Room>.from(
Matrix.of(context).client.rooms);
rooms.removeWhere((Room room) =>
room.lastEvent == null ||
(searchMode &&
!room.displayname.toLowerCase().contains(
searchController.text.toLowerCase() ??
'')));
if (rooms.isEmpty && (!searchMode)) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
searchMode
? Icons.search_outlined
: Icons.maps_ugc_outlined,
size: 80,
color: Colors.grey,
),
Text(
searchMode
? L10n.of(context).noRoomsFound
: L10n.of(context).startYourFirstChat,
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
);
}
final totalCount = rooms.length;
return ListView.builder(
controller: _scrollController,
itemCount: totalCount,
itemBuilder: (BuildContext context, int i) =>
ChatListItem(
rooms[i],
selected:
_selectedRoomIds.contains(rooms[i].id),
onTap: selectMode == SelectMode.select
? () => _toggleSelection(rooms[i].id)
: null,
onLongPress: selectMode != SelectMode.share
? () => _toggleSelection(rooms[i].id)
: null,
activeChat: widget.activeChat == rooms[i].id,
),
);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
},
);
}),
),
],
),
);
});
}
}

View File

@ -1,238 +0,0 @@
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/components/avatar.dart';
import 'package:fluffychat/components/default_app_bar_search_field.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class DiscoverPage extends StatefulWidget {
final String alias;
const DiscoverPage({Key key, this.alias}) : super(key: key);
@override
_DiscoverPageState createState() => _DiscoverPageState();
}
class _DiscoverPageState extends State<DiscoverPage> {
final ScrollController _scrollController = ScrollController();
bool _scrolledToTop = true;
Future<PublicRoomsResponse> _publicRoomsResponse;
Timer _coolDown;
String _server;
String _genericSearchTerm;
void _search(BuildContext context, String query) async {
_coolDown?.cancel();
_coolDown = Timer(
Duration(milliseconds: 500),
() => setState(() {
_genericSearchTerm = query;
_publicRoomsResponse = null;
}),
);
}
void _setServer(BuildContext context) async {
final newServer = await showTextInputDialog(
title: L10n.of(context).changeTheHomeserver,
context: context,
textFields: [
DialogTextField(
hintText: Matrix.of(context).client.homeserver.toString(),
initialText: _server,
keyboardType: TextInputType.url,
)
]);
if (newServer == null) return;
setState(() {
_server = newServer.single;
_publicRoomsResponse = null;
});
}
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,
) ==
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}');
}
}
@override
void initState() {
_genericSearchTerm = widget.alias;
_scrollController.addListener(() async {
if (_scrollController.position.pixels > 0 && _scrolledToTop) {
setState(() => _scrolledToTop = false);
} else if (_scrollController.position.pixels == 0 && !_scrolledToTop) {
setState(() => _scrolledToTop = true);
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
final server = _genericSearchTerm?.isValidMatrixId ?? false
? _genericSearchTerm.domain
: _server;
_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) ||
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;
});
return Scaffold(
appBar: AppBar(
leading: BackButton(),
titleSpacing: 0,
elevation: _scrolledToTop ? 0 : null,
title: DefaultAppBarSearchField(
onChanged: (text) => _search(context, text),
hintText: L10n.of(context).searchForAChat,
suffix: IconButton(
icon: Icon(Icons.edit_outlined),
onPressed: () => _setServer(context),
),
),
),
body: FutureBuilder<PublicRoomsResponse>(
future: _publicRoomsResponse,
builder: (BuildContext context,
AsyncSnapshot<PublicRoomsResponse> snapshot) {
if (snapshot.hasError) {
return Center(child: Text(snapshot.error.toString()));
}
if (snapshot.connectionState != ConnectionState.done) {
return Center(child: CircularProgressIndicator());
}
final publicRoomsResponse = snapshot.data;
if (publicRoomsResponse.chunk.isEmpty) {
return Center(
child: Text(
'No public groups found...',
textAlign: TextAlign.center,
),
);
}
return GridView.builder(
padding: EdgeInsets.all(12),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
controller: _scrollController,
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,
),
],
),
),
),
),
);
},
),
);
}
}

250
lib/views/home_view.dart Normal file
View File

@ -0,0 +1,250 @@
import 'dart:async';
import 'dart:io';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/views/home_view_parts/discover.dart';
import 'package:fluffychat/views/share_view.dart';
import 'package:flutter/cupertino.dart';
import 'package:fluffychat/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:preload_page_view/preload_page_view.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import '../components/matrix.dart';
import '../utils/matrix_file_extension.dart';
import '../utils/url_launcher.dart';
import 'home_view_parts/chat_list.dart';
import 'home_view_parts/status_list.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
enum SelectMode { normal, share, select }
class HomeView extends StatefulWidget {
final String activeChat;
const HomeView({this.activeChat, Key key}) : super(key: key);
@override
_HomeViewState createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
@override
void initState() {
_initReceiveSharingIntent();
super.initState();
}
int currentIndex = 1;
StreamSubscription _intentDataStreamSubscription;
StreamSubscription _intentFileStreamSubscription;
StreamSubscription _onShareContentChanged;
AppBar appBar;
final PreloadPageController _pageController =
PreloadPageController(initialPage: 1);
void _onShare(Map<String, dynamic> content) {
if (content != null) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ShareView(),
),
),
);
}
}
void _processIncomingSharedFiles(List<SharedMediaFile> files) {
if (files?.isEmpty ?? true) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
final file = File(files.first.path);
Matrix.of(context).shareContent = {
'msgtype': 'chat.fluffy.shared_file',
'file': MatrixFile(
bytes: file.readAsBytesSync(),
name: file.path,
).detectFileType,
};
}
void _processIncomingSharedText(String text) {
if (text == null) return;
AdaptivePageLayout.of(context).popUntilIsFirst();
if (text.toLowerCase().startsWith(AppConfig.inviteLinkPrefix) ||
(text.toLowerCase().startsWith(AppConfig.schemePrefix) &&
!RegExp(r'\s').hasMatch(text))) {
UrlLauncher(context, text).openMatrixToUrl();
return;
}
Matrix.of(context).shareContent = {
'msgtype': 'm.text',
'body': text,
};
}
void _initReceiveSharingIntent() {
if (!PlatformInfos.isMobile) return;
// For sharing images coming from outside the app while the app is in the memory
_intentFileStreamSubscription = ReceiveSharingIntent.getMediaStream()
.listen(_processIncomingSharedFiles, onError: print);
// For sharing images coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then(_processIncomingSharedFiles);
// For sharing or opening urls/text coming from outside the app while the app is in the memory
_intentDataStreamSubscription = ReceiveSharingIntent.getTextStream()
.listen(_processIncomingSharedText, onError: print);
// For sharing or opening urls/text coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialText().then(_processIncomingSharedText);
}
@override
void dispose() {
_intentDataStreamSubscription?.cancel();
_intentFileStreamSubscription?.cancel();
super.dispose();
}
String _server;
void _setServer(BuildContext context) async {
final newServer = await showTextInputDialog(
title: L10n.of(context).changeTheHomeserver,
context: context,
textFields: [
DialogTextField(
prefixText: 'https://',
hintText: Matrix.of(context).client.homeserver.host,
initialText: _server,
keyboardType: TextInputType.url,
)
]);
if (newServer == null) return;
setState(() {
_server = newServer.single;
});
}
void _onFabTab() {
switch (currentIndex) {
case 0:
AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/newstatus');
break;
case 1:
AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/newprivatechat');
break;
case 2:
AdaptivePageLayout.of(context)
.pushNamedAndRemoveUntilIsFirst('/newgroup');
break;
case 3:
_setServer(context);
break;
}
}
@override
Widget build(BuildContext context) {
_onShareContentChanged ??=
Matrix.of(context).onShareContentChanged.stream.listen(_onShare);
IconData fabIcon;
switch (currentIndex) {
case 0:
fabIcon = Icons.edit_outlined;
break;
case 1:
fabIcon = Icons.add_outlined;
break;
case 2:
fabIcon = Icons.group_add_outlined;
break;
case 3:
fabIcon = Icons.domain_outlined;
break;
}
return Scaffold(
appBar: appBar ??
AppBar(
centerTitle: false,
actions: [
IconButton(
icon: Icon(Icons.account_circle_outlined),
onPressed: () =>
AdaptivePageLayout.of(context).pushNamed('/settings'),
),
],
title: Text(AppConfig.applicationName)),
body: PreloadPageView(
controller: _pageController,
onPageChanged: (i) => setState(() => currentIndex = i),
children: [
StatusList(key: Key('StatusList')),
ChatList(
type: ChatListType.messages,
onCustomAppBar: (appBar) => setState(() => this.appBar = appBar),
),
ChatList(
type: ChatListType.groups,
onCustomAppBar: (appBar) => setState(() => this.appBar = appBar),
),
Discover(server: _server),
],
),
floatingActionButton: FloatingActionButton(
child: Icon(fabIcon),
onPressed: _onFabTab,
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: BottomNavigationBar(
unselectedItemColor: Colors.black,
currentIndex: currentIndex,
showSelectedLabels: true,
showUnselectedLabels: false,
type: BottomNavigationBarType.fixed,
elevation: 20,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
onTap: (i) {
_pageController.animateToPage(
i,
duration: Duration(milliseconds: 200),
curve: Curves.bounceOut,
);
setState(() => currentIndex = i);
},
items: [
BottomNavigationBarItem(
label: L10n.of(context).status,
icon: Icon(Icons.home_outlined),
),
BottomNavigationBarItem(
label: L10n.of(context).messages,
icon: Icon(CupertinoIcons.chat_bubble_2),
),
BottomNavigationBarItem(
label: L10n.of(context).groups,
icon: Icon(Icons.people_outline),
),
BottomNavigationBarItem(
label: L10n.of(context).discover,
icon: Icon(CupertinoIcons.search_circle),
),
],
),
);
}
}

View File

@ -0,0 +1,246 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/components/connection_status_header.dart';
import 'package:fluffychat/components/default_app_bar_search_field.dart';
import 'package:fluffychat/components/list_items/chat_list_item.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
enum ChatListType { messages, groups, all }
enum SelectMode { normal, select }
class ChatList extends StatefulWidget {
final String activeChat;
final ChatListType type;
final void Function(AppBar appBar) onCustomAppBar;
const ChatList({
Key key,
this.activeChat,
@required this.type,
this.onCustomAppBar,
}) : super(key: key);
@override
_ChatListState createState() => _ChatListState();
}
class _ChatListState extends State<ChatList> {
bool get searchMode => searchController.text?.isNotEmpty ?? false;
final TextEditingController searchController = TextEditingController();
final _selectedRoomIds = <String>{};
void _toggleSelection(String roomId) {
setState(() => _selectedRoomIds.contains(roomId)
? _selectedRoomIds.remove(roomId)
: _selectedRoomIds.add(roomId));
widget.onCustomAppBar(
_selectedRoomIds.isEmpty
? null
: AppBar(
centerTitle: false,
leading: IconButton(
icon: Icon(Icons.close_outlined),
onPressed: () {
_selectedRoomIds.clear();
widget.onCustomAppBar(null);
},
),
title: Text(
L10n.of(context)
.numberSelected(_selectedRoomIds.length.toString()),
),
actions: [
if (_selectedRoomIds.length == 1)
IconButton(
tooltip: L10n.of(context).toggleUnread,
icon: Icon(Matrix.of(context)
.client
.getRoomById(_selectedRoomIds.single)
.isUnread
? Icons.mark_chat_read_outlined
: Icons.mark_chat_unread_outlined),
onPressed: () => _toggleUnread(context),
),
if (_selectedRoomIds.length == 1)
IconButton(
tooltip: L10n.of(context).toggleFavorite,
icon: Icon(Icons.push_pin_outlined),
onPressed: () => _toggleFavouriteRoom(context),
),
if (_selectedRoomIds.length == 1)
IconButton(
icon: Icon(Matrix.of(context)
.client
.getRoomById(_selectedRoomIds.single)
.pushRuleState ==
PushRuleState.notify
? Icons.notifications_off_outlined
: Icons.notifications_outlined),
tooltip: L10n.of(context).toggleMuted,
onPressed: () => _toggleMuted(context),
),
IconButton(
icon: Icon(Icons.archive_outlined),
tooltip: L10n.of(context).archive,
onPressed: () => _archiveAction(context),
),
],
),
);
}
Future<void> _toggleUnread(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(_selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setUnread(!room.isUnread),
);
}
Future<void> _toggleFavouriteRoom(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(_selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setFavourite(!room.isFavourite),
);
}
Future<void> _toggleMuted(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(_selectedRoomIds.single);
return showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentions_only
: PushRuleState.notify),
);
}
Future<void> _archiveAction(BuildContext context) async {
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
) ==
OkCancelResult.ok;
if (!confirmed) return;
await showFutureLoadingDialog(
context: context,
future: () => _archiveSelectedRooms(context),
);
setState(() => null);
}
Future<void> _archiveSelectedRooms(BuildContext context) async {
final client = Matrix.of(context).client;
while (_selectedRoomIds.isNotEmpty) {
final roomId = _selectedRoomIds.first;
await client.getRoomById(roomId).leave();
_toggleSelection(roomId);
}
}
Future<void> waitForFirstSync(BuildContext context) async {
var client = Matrix.of(context).client;
if (client.prevBatch?.isEmpty ?? true) {
await client.onFirstSync.stream.first;
}
return true;
}
@override
Widget build(BuildContext context) {
final selectMode =
_selectedRoomIds.isEmpty ? SelectMode.normal : SelectMode.select;
return Column(children: [
ConnectionStatusHeader(),
Expanded(
child: StreamBuilder(
stream: Matrix.of(context)
.client
.onSync
.stream
.where((s) => s.hasRoomUpdate),
builder: (context, snapshot) {
return FutureBuilder<void>(
future: waitForFirstSync(context),
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
var rooms =
List<Room>.from(Matrix.of(context).client.rooms);
rooms.removeWhere((room) =>
room.lastEvent == null ||
(searchMode &&
!room.displayname.toLowerCase().contains(
searchController.text.toLowerCase() ?? '')));
if (widget.type == ChatListType.messages) {
rooms.removeWhere((room) => !room.isDirectChat);
} else if (widget.type == ChatListType.groups) {
rooms.removeWhere((room) => room.isDirectChat);
}
if (rooms.isEmpty && (!searchMode)) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
searchMode
? Icons.search_outlined
: Icons.maps_ugc_outlined,
size: 80,
color: Colors.grey,
),
Text(
searchMode
? L10n.of(context).noRoomsFound
: L10n.of(context).startYourFirstChat,
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
);
}
final totalCount = rooms.length;
return ListView.builder(
itemCount: totalCount + 1,
itemBuilder: (BuildContext context, int i) => i == 0
? Padding(
padding: EdgeInsets.all(12),
child: DefaultAppBarSearchField(
hintText: L10n.of(context).search,
prefixIcon: Icon(Icons.search_outlined),
searchController: searchController,
onChanged: (_) => setState(() => null),
padding: EdgeInsets.zero,
),
)
: ChatListItem(
rooms[i - 1],
selected:
_selectedRoomIds.contains(rooms[i - 1].id),
onTap: selectMode == SelectMode.select &&
widget.onCustomAppBar != null
? () => _toggleSelection(rooms[i - 1].id)
: null,
onLongPress: widget.onCustomAppBar != null
? () => _toggleSelection(rooms[i - 1].id)
: null,
activeChat: widget.activeChat == rooms[i - 1].id,
),
);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
},
);
}),
),
]);
}
}

View File

@ -0,0 +1,215 @@
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/components/avatar.dart';
import 'package:fluffychat/components/default_app_bar_search_field.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class Discover extends StatefulWidget {
final String alias;
final String server;
const Discover({
Key key,
this.alias,
this.server,
}) : super(key: key);
@override
_DiscoverState createState() => _DiscoverState();
}
class _DiscoverState extends State<Discover> {
Future<PublicRoomsResponse> _publicRoomsResponse;
Timer _coolDown;
String _genericSearchTerm;
void _search(BuildContext context, String query) async {
_coolDown?.cancel();
_coolDown = Timer(
Duration(milliseconds: 500),
() => setState(() {
_genericSearchTerm = query;
_publicRoomsResponse = null;
}),
);
}
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,
) ==
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}');
}
}
@override
void initState() {
_genericSearchTerm = widget.alias;
super.initState();
}
@override
Widget build(BuildContext context) {
final server = _genericSearchTerm?.isValidMatrixId ?? false
? _genericSearchTerm.domain
: widget.server;
_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) ||
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;
});
return ListView(
children: [
Padding(
padding: EdgeInsets.all(12),
child: DefaultAppBarSearchField(
hintText: L10n.of(context).search,
prefixIcon: Icon(Icons.search_outlined),
onChanged: (t) => _search(context, t),
padding: EdgeInsets.zero,
),
),
FutureBuilder<PublicRoomsResponse>(
future: _publicRoomsResponse,
builder: (BuildContext context,
AsyncSnapshot<PublicRoomsResponse> snapshot) {
if (snapshot.hasError) {
return Center(child: Text(snapshot.error.toString()));
}
if (snapshot.connectionState != ConnectionState.done) {
return Center(child: CircularProgressIndicator());
}
final publicRoomsResponse = snapshot.data;
if (publicRoomsResponse.chunk.isEmpty) {
return Center(
child: Text(
'No public groups found...',
textAlign: TextAlign.center,
),
);
}
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,
),
],
),
),
),
),
);
}),
],
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:fluffychat/components/list_items/status_list_tile.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:fluffychat/utils/status.dart';
import 'package:flutter/material.dart';
class StatusList extends StatefulWidget {
const StatusList({Key key}) : super(key: key);
@override
_StatusListState createState() => _StatusListState();
}
class _StatusListState extends State<StatusList> {
bool _onlyContacts = false;
@override
Widget build(BuildContext context) {
return ListView(children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RaisedButton(
elevation: _onlyContacts ? 7 : null,
color: !_onlyContacts ? null : Theme.of(context).primaryColor,
child: Text(
'Contacts',
style: TextStyle(color: _onlyContacts ? Colors.white : null),
),
onPressed: () => setState(() => _onlyContacts = true),
),
RaisedButton(
elevation: !_onlyContacts ? 7 : null,
color: _onlyContacts ? null : Theme.of(context).primaryColor,
child: Text(
'All',
style: TextStyle(color: !_onlyContacts ? Colors.white : null),
),
onPressed: () => setState(() => _onlyContacts = false),
),
],
),
Divider(height: 1),
StreamBuilder<Object>(
stream: Matrix.of(context)
.client
.onAccountData
.stream
.where((a) => a.type == Status.namespace),
builder: (context, snapshot) {
final statuses = Matrix.of(context).statuses.values.toList()
..sort((a, b) => b.dateTime.compareTo(a.dateTime));
if (_onlyContacts) {
final client = Matrix.of(context).client;
statuses.removeWhere(
(p) => client.getDirectChatFromUserId(p.senderId) == null);
}
return ListView.separated(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: EdgeInsets.only(bottom: 24),
separatorBuilder: (_, __) => Divider(height: 1),
itemCount: statuses.length,
itemBuilder: (context, i) => StatusListTile(status: statuses[i]),
);
}),
]);
}
}

View File

@ -0,0 +1,166 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/material.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:image_picker/image_picker.dart';
import '../utils/string_color.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class SetStatusView extends StatefulWidget {
final String initialText;
const SetStatusView({Key key, this.initialText}) : super(key: key);
@override
_SetStatusViewState createState() => _SetStatusViewState();
}
class _SetStatusViewState extends State<SetStatusView> {
Color _color;
final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.text = widget.initialText;
}
void _setStatusAction(BuildContext context, [String message]) async {
final result = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.sendPresence(
Matrix.of(context).client.userID,
PresenceType.online,
statusMsg: message ?? _controller.text,
),
);
if (result.error == null) AdaptivePageLayout.of(context).pop();
}
void _setCameraImageStatusAction(BuildContext context) async {
MatrixFile file;
if (PlatformInfos.isMobile) {
final result = await ImagePicker().getImage(
source: ImageSource.camera,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (result == null) return;
file = MatrixFile(
bytes: await result.readAsBytes(),
name: result.path,
);
}
final uploadResp = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.upload(file.bytes, file.name),
);
if (uploadResp.error == null) {
return _setStatusAction(context, uploadResp.result);
}
}
void _setImageStatusAction(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 uploadResp = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.upload(file.bytes, file.name),
);
if (uploadResp.error == null) {
return _setStatusAction(context, uploadResp.result);
}
}
@override
Widget build(BuildContext context) {
_color ??= Theme.of(context).primaryColor;
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5),
title: Text(L10n.of(context).statusExampleMessage),
actions: [
IconButton(
icon: Icon(Icons.info_outlined),
onPressed: () => showOkAlertDialog(
context: context,
title: L10n.of(context).setStatus,
message:
'Show your status to all users you share a room. Every status will replace the previous one. Be aware that statuses are public and therefore not end-to-end encrypted.',
),
),
],
),
body: AnimatedContainer(
duration: Duration(seconds: 2),
alignment: Alignment.center,
color: _color,
child: SingleChildScrollView(
child: TextField(
minLines: 1,
maxLines: 10,
autofocus: true,
textAlign: TextAlign.center,
controller: _controller,
onChanged: (s) => setState(() => _color = s.color),
style: TextStyle(fontSize: 40, color: Colors.white),
decoration: InputDecoration(
border: InputBorder.none,
filled: false,
),
),
),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (PlatformInfos.isMobile) ...{
FloatingActionButton(
backgroundColor: Colors.white,
foregroundColor: Theme.of(context).primaryColor,
child: Icon(Icons.camera_alt_outlined),
onPressed: () => _setCameraImageStatusAction(context),
),
SizedBox(height: 12),
},
FloatingActionButton(
backgroundColor: Colors.white,
foregroundColor: Theme.of(context).primaryColor,
child: Icon(Icons.image_outlined),
onPressed: () => _setImageStatusAction(context),
),
SizedBox(height: 12),
FloatingActionButton(
child: Icon(Icons.send_outlined),
onPressed: () => _setStatusAction(context),
),
],
),
);
}
}

View File

@ -6,9 +6,26 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../components/matrix.dart'; import '../components/matrix.dart';
class SettingsIgnoreList extends StatelessWidget { 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(); final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
if (widget.initialUserId != null) {
_controller.text = widget.initialUserId.replaceAll('@', '');
}
}
void _ignoreUser(BuildContext context) { void _ignoreUser(BuildContext context) {
if (_controller.text.isEmpty) return; if (_controller.text.isEmpty) return;
final userId = '@${_controller.text}'; final userId = '@${_controller.text}';

27
lib/views/share_view.dart Normal file
View File

@ -0,0 +1,27 @@
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'home_view_parts/chat_list.dart';
class ShareView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: Icon(Icons.close_outlined),
onPressed: () {
Matrix.of(context).shareContent = null;
AdaptivePageLayout.of(context).pop();
},
),
title: Text(L10n.of(context).share),
),
body: ChatList(
type: ChatListType.all,
),
);
}
}

View File

@ -176,6 +176,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.16.2" version: "0.16.2"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
dapackages: dapackages:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -865,6 +872,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.5.0-nullsafety.2" version: "1.5.0-nullsafety.2"
preload_page_view:
dependency: "direct main"
description:
name: preload_page_view
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.4"
process: process:
dependency: transitive dependency: transitive
description: description:

View File

@ -20,6 +20,7 @@ dependencies:
url: https://github.com/UnifiedPush/flutter-connector.git url: https://github.com/UnifiedPush/flutter-connector.git
ref: main ref: main
cupertino_icons: any
localstorage: ^3.0.6+9 localstorage: ^3.0.6+9
file_picker_cross: 4.2.2 file_picker_cross: 4.2.2
image_picker: ^0.6.7+21 image_picker: ^0.6.7+21
@ -30,6 +31,7 @@ dependencies:
adaptive_page_layout: ^0.1.6 adaptive_page_layout: ^0.1.6
provider: ^4.3.3 provider: ^4.3.3
adaptive_theme: ^1.1.0 adaptive_theme: ^1.1.0
preload_page_view: ^0.1.4
# desktop_notifications: ^0.0.0-dev.4 // Currently blocked by: https://github.com/canonical/desktop_notifications.dart/issues/5 # desktop_notifications: ^0.0.0-dev.4 // Currently blocked by: https://github.com/canonical/desktop_notifications.dart/issues/5
matrix_link_text: ^0.3.2 matrix_link_text: ^0.3.2
path_provider: ^1.6.27 path_provider: ^1.6.27