From e728ccc1ba70bfcda8a7dd059f7374c207ab2db2 Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Sun, 6 Dec 2020 12:51:40 +0100 Subject: [PATCH] feat: Implement discover groups page --- .../default_app_bar_search_field.dart | 54 ++++ lib/components/default_drawer.dart | 104 ++++++++ lib/l10n/intl_en.arb | 10 + lib/utils/room_status_extension.dart | 3 + lib/views/archive.dart | 17 ++ lib/views/chat_list.dart | 249 +++--------------- lib/views/discover_view.dart | 212 +++++++++++++++ lib/views/empty_page.dart | 12 + 8 files changed, 443 insertions(+), 218 deletions(-) create mode 100644 lib/components/default_app_bar_search_field.dart create mode 100644 lib/components/default_drawer.dart create mode 100644 lib/views/discover_view.dart create mode 100644 lib/views/empty_page.dart diff --git a/lib/components/default_app_bar_search_field.dart b/lib/components/default_app_bar_search_field.dart new file mode 100644 index 00000000..44e88794 --- /dev/null +++ b/lib/components/default_app_bar_search_field.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class DefaultAppBarSearchField extends StatelessWidget { + final TextEditingController searchController; + final void Function(String) onChanged; + final Widget suffix; + + const DefaultAppBarSearchField({ + Key key, + this.searchController, + this.onChanged, + this.suffix, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final focusNode = FocusNode(); + return Container( + height: 40, + padding: EdgeInsets.only(right: 16), + child: Material( + color: Theme.of(context).secondaryHeaderColor, + borderRadius: BorderRadius.circular(32), + child: TextField( + autocorrect: false, + controller: searchController, + onChanged: onChanged, + focusNode: focusNode, + decoration: InputDecoration( + contentPadding: EdgeInsets.only( + top: 8, + bottom: 8, + left: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(32), + ), + hintText: L10n.of(context).searchForAChat, + suffixIcon: focusNode.hasFocus + ? IconButton( + icon: Icon(Icons.backspace_outlined), + onPressed: () { + searchController.clear(); + focusNode.unfocus(); + }, + ) + : suffix, + ), + ), + ), + ); + } +} diff --git a/lib/components/default_drawer.dart b/lib/components/default_drawer.dart new file mode 100644 index 00000000..7741474b --- /dev/null +++ b/lib/components/default_drawer.dart @@ -0,0 +1,104 @@ +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:fluffychat/utils/app_route.dart'; +import 'package:fluffychat/views/archive.dart'; +import 'package:fluffychat/views/discover_view.dart'; +import 'package:fluffychat/views/new_group.dart'; +import 'package:fluffychat/views/new_private_chat.dart'; +import 'package:fluffychat/views/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'dialogs/simple_dialogs.dart'; +import 'matrix.dart'; + +class DefaultDrawer extends StatelessWidget { + void _drawerTapAction(BuildContext context, Widget view) { + Navigator.of(context).pop(); + Navigator.of(context).pushAndRemoveUntil( + AppRoute.defaultRoute( + context, + view, + ), + (r) => r.isFirst, + ); + } + + void _setStatus(BuildContext context) async { + Navigator.of(context).pop(); + 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; + final client = Matrix.of(context).client; + await SimpleDialogs(context).tryRequestWithLoadingDialog( + client.sendPresence( + client.userID, + PresenceType.online, + statusMsg: input.single, + ), + ); + return; + } + + @override + Widget build(BuildContext context) { + return Drawer( + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + 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, NewGroupView()), + ), + ListTile( + leading: Icon(Icons.person_add_outlined), + title: Text(L10n.of(context).newPrivateChat), + onTap: () => _drawerTapAction(context, NewPrivateChatView()), + ), + 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, + DiscoverView(), + ), + ), + Divider(height: 1), + ListTile( + leading: Icon(Icons.settings_outlined), + title: Text(L10n.of(context).settings), + onTap: () => _drawerTapAction( + context, + SettingsView(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 20ef9682..b3285263 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1370,6 +1370,16 @@ "count": {} } }, + "discoverGroups": "Discover groups", + "@discoverGroups": { + "type": "text", + "placeholders": {} + }, + "noDescription": "No description", + "@noDescription": { + "type": "text", + "placeholders": {} + }, "editBlockedServers": "Edit blocked servers", "@editBlockedServers": { "type": "text", diff --git a/lib/utils/room_status_extension.dart b/lib/utils/room_status_extension.dart index 041d6422..381becde 100644 --- a/lib/utils/room_status_extension.dart +++ b/lib/utils/room_status_extension.dart @@ -13,6 +13,9 @@ extension RoomStatusExtension on Room { directChatPresence.presence != null && (directChatPresence.presence.lastActiveAgo != null || directChatPresence.presence.currentlyActive != null)) { + if (directChatPresence.presence.statusMsg?.isNotEmpty ?? false) { + return directChatPresence.presence.statusMsg; + } if (directChatPresence.presence.currentlyActive == true) { return L10n.of(context).currentlyActive; } diff --git a/lib/views/archive.dart b/lib/views/archive.dart index c4fad9ca..4261d81e 100644 --- a/lib/views/archive.dart +++ b/lib/views/archive.dart @@ -18,12 +18,28 @@ class _ArchiveState extends State { return await Matrix.of(context).client.archive; } + final ScrollController _scrollController = ScrollController(); + bool _scrolledToTop = 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); + } + }); + super.initState(); + } + @override Widget build(BuildContext context) { return AdaptivePageLayout( firstScaffold: Scaffold( appBar: AppBar( title: Text(L10n.of(context).archive), + elevation: _scrolledToTop ? 0 : null, ), body: FutureBuilder>( future: getArchive(context), @@ -33,6 +49,7 @@ class _ArchiveState extends State { } else { archive = snapshot.data; return ListView.builder( + controller: _scrollController, itemCount: archive.length, itemBuilder: (BuildContext context, int i) => ChatListItem( archive[i], diff --git a/lib/views/chat_list.dart b/lib/views/chat_list.dart index 148cfc70..346dbd14 100644 --- a/lib/views/chat_list.dart +++ b/lib/views/chat_list.dart @@ -3,12 +3,11 @@ import 'dart:io'; import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:famedlysdk/famedlysdk.dart'; -import 'package:famedlysdk/matrix_api.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:fluffychat/components/dialogs/simple_dialogs.dart'; -import 'package:fluffychat/components/list_items/public_room_list_item.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -21,11 +20,9 @@ import '../components/matrix.dart'; import '../utils/app_route.dart'; import '../utils/matrix_file_extension.dart'; import '../utils/url_launcher.dart'; -import 'archive.dart'; +import 'empty_page.dart'; import 'homeserver_picker.dart'; -import 'new_group.dart'; import 'new_private_chat.dart'; -import 'settings.dart'; enum SelectMode { normal, share, select } @@ -35,11 +32,7 @@ class ChatListView extends StatelessWidget { return AdaptivePageLayout( primaryPage: FocusPage.FIRST, firstScaffold: ChatList(), - secondScaffold: Scaffold( - body: Center( - child: Image.asset('assets/logo.png', width: 100, height: 100), - ), - ), + secondScaffold: EmptyPage(), ); } } @@ -56,14 +49,10 @@ class ChatList extends StatefulWidget { class _ChatListState extends State { bool get searchMode => searchController.text?.isNotEmpty ?? false; final TextEditingController searchController = TextEditingController(); - final FocusNode _searchFocusNode = FocusNode(); - Timer coolDown; - PublicRoomsResponse publicRoomsResponse; - bool loadingPublicRooms = false; - String searchServer; final _selectedRoomIds = {}; final ScrollController _scrollController = ScrollController(); + bool _scrolledToTop = true; void _toggleSelection(String roomId) => setState(() => _selectedRoomIds.contains(roomId) @@ -78,8 +67,6 @@ class _ChatListState extends State { return true; } - bool _scrolledToTop = true; - @override void initState() { _scrollController.addListener(() async { @@ -89,46 +76,6 @@ class _ChatListState extends State { setState(() => _scrolledToTop = true); } }); - searchController.addListener(() { - coolDown?.cancel(); - if (searchController.text.isEmpty) { - setState(() { - loadingPublicRooms = false; - publicRoomsResponse = null; - }); - return; - } - coolDown = Timer(Duration(seconds: 1), () async { - setState(() => loadingPublicRooms = true); - final newPublicRoomsResponse = - await SimpleDialogs(context).tryRequestWithErrorToast( - Matrix.of(context).client.searchPublicRooms( - limit: 30, - includeAllNetworks: true, - genericSearchTerm: searchController.text, - server: searchServer, - ), - ); - setState(() { - loadingPublicRooms = false; - if (newPublicRoomsResponse != false) { - publicRoomsResponse = newPublicRoomsResponse; - if (searchController.text.isNotEmpty && - searchController.text.isValidMatrixId && - searchController.text.sigil == '#') { - publicRoomsResponse.chunk.add( - PublicRoom.fromJson({ - 'aliases': [searchController.text], - 'name': searchController.text, - 'room_id': searchController.text, - }), - ); - } - } - }); - }); - setState(() => null); - }); _initReceiveSharingIntent(); super.initState(); } @@ -186,45 +133,8 @@ class _ChatListState extends State { ReceiveSharingIntent.getInitialText().then(_processIncomingSharedText); } - void _drawerTapAction(Widget view) { - Navigator.of(context).pop(); - Navigator.of(context).pushAndRemoveUntil( - AppRoute.defaultRoute( - context, - view, - ), - (r) => r.isFirst, - ); - } - - void _setStatus(BuildContext context) async { - Navigator.of(context).pop(); - 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; - final client = Matrix.of(context).client; - await SimpleDialogs(context).tryRequestWithLoadingDialog( - client.sendPresence( - client.userID, - PresenceType.online, - statusMsg: input.single, - ), - ); - return; - } - @override void dispose() { - searchController.removeListener( - () => setState(() => null), - ); _intentDataStreamSubscription?.cancel(); _intentFileStreamSubscription?.cancel(); super.dispose(); @@ -292,62 +202,8 @@ class _ChatListState extends State { _selectedRoomIds.clear(); } return Scaffold( - drawer: selectMode != SelectMode.normal - ? null - : Drawer( - child: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - 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(NewGroupView()), - ), - ListTile( - leading: Icon(Icons.person_add_outlined), - title: Text(L10n.of(context).newPrivateChat), - onTap: () => - _drawerTapAction(NewPrivateChatView()), - ), - Divider(height: 1), - ListTile( - leading: Icon(Icons.archive_outlined), - title: Text(L10n.of(context).archive), - onTap: () => _drawerTapAction( - Archive(), - ), - ), - ListTile( - leading: Icon(Icons.settings_outlined), - title: Text(L10n.of(context).settings), - onTap: () => _drawerTapAction( - SettingsView(), - ), - ), - Divider(height: 1), - ListTile( - leading: Icon(Icons.share_outlined), - title: Text(L10n.of(context).inviteContact), - onTap: () { - Navigator.of(context).pop(); - FluffyShare.share( - L10n.of(context).inviteText( - Matrix.of(context).client.userID, - 'https://matrix.to/#/${Matrix.of(context).client.userID}'), - context); - }, - ), - ], - ), - ), - ), + drawer: + selectMode != SelectMode.normal ? null : DefaultDrawer(), appBar: AppBar( centerTitle: false, elevation: _scrolledToTop ? 0 : null, @@ -387,39 +243,9 @@ class _ChatListState extends State { ? Text(L10n.of(context).share) : selectMode == SelectMode.select ? Text(_selectedRoomIds.length.toString()) - : Container( - height: 40, - padding: EdgeInsets.only(right: 8), - child: Material( - color: Theme.of(context).secondaryHeaderColor, - borderRadius: BorderRadius.circular(32), - child: TextField( - autocorrect: false, - controller: searchController, - focusNode: _searchFocusNode, - decoration: InputDecoration( - contentPadding: EdgeInsets.only( - top: 8, - bottom: 8, - left: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(32), - ), - hintText: L10n.of(context).searchForAChat, - suffixIcon: searchMode - ? IconButton( - icon: Icon( - Icons.backspace_outlined), - onPressed: () => setState(() { - searchController.clear(); - _searchFocusNode.unfocus(); - }), - ) - : null, - ), - ), - ), + : DefaultAppBarSearchField( + searchController: searchController, + onChanged: (_) => setState(() => null), ), ), floatingActionButton: AdaptivePageLayout.columnMode(context) @@ -463,9 +289,7 @@ class _ChatListState extends State { .contains(searchController.text .toLowerCase() ?? ''))); - if (rooms.isEmpty && - (!searchMode || - publicRoomsResponse == null)) { + if (rooms.isEmpty && (!searchMode)) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -485,16 +309,12 @@ class _ChatListState extends State { ), ); } - final publicRoomsCount = - (publicRoomsResponse?.chunk?.length ?? - 0); - final totalCount = - rooms.length + publicRoomsCount; + final totalCount = rooms.length; return ListView.separated( controller: _scrollController, separatorBuilder: (BuildContext context, int i) => - i == totalCount - publicRoomsCount + i == totalCount ? ListTile( title: Text( L10n.of(context) @@ -510,31 +330,24 @@ class _ChatListState extends State { ) : Container(), itemCount: totalCount, - itemBuilder: (BuildContext context, - int i) => - i < rooms.length - ? 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, - ) - : PublicRoomListItem( - publicRoomsResponse - .chunk[i - rooms.length], - ), + 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( diff --git a/lib/views/discover_view.dart b/lib/views/discover_view.dart new file mode 100644 index 00000000..4aace497 --- /dev/null +++ b/lib/views/discover_view.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:fluffychat/components/adaptive_page_layout.dart'; +import 'package:fluffychat/components/avatar.dart'; +import 'package:fluffychat/components/default_app_bar_search_field.dart'; +import 'package:fluffychat/components/dialogs/simple_dialogs.dart'; +import 'package:fluffychat/components/matrix.dart'; +import 'package:fluffychat/utils/app_route.dart'; +import 'package:fluffychat/views/chat.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'empty_page.dart'; + +class DiscoverView extends StatelessWidget { + @override + Widget build(BuildContext context) { + return AdaptivePageLayout( + firstScaffold: DiscoverPage(), + secondScaffold: EmptyPage(), + ); + } +} + +class DiscoverPage extends StatefulWidget { + @override + _DiscoverPageState createState() => _DiscoverPageState(); +} + +class _DiscoverPageState extends State { + final ScrollController _scrollController = ScrollController(); + bool _scrolledToTop = true; + Future _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 _joinRoomAndWait(BuildContext context, String roomId) async { + final newRoomId = await Matrix.of(context).client.joinRoomOrAlias(roomId); + if (Matrix.of(context).client.getRoomById(newRoomId) == null) { + 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 SimpleDialogs(context) + .tryRequestWithLoadingDialog(_joinRoomAndWait(context, room.roomId)); + if (success != false) { + await Navigator.of(context).push( + AppRoute.defaultRoute( + context, + ChatView(success), + ), + ); + } + } + + @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); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + _publicRoomsResponse ??= Matrix.of(context).client.searchPublicRooms( + server: _server, + genericSearchTerm: _genericSearchTerm, + ); + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + elevation: _scrolledToTop ? 0 : null, + title: DefaultAppBarSearchField( + onChanged: (text) => _search(context, text), + suffix: IconButton( + icon: Icon(Icons.edit_outlined), + onPressed: () => _setServer(context), + ), + ), + ), + body: FutureBuilder( + future: _publicRoomsResponse, + builder: (BuildContext context, + AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return Center(child: Text(snapshot.error.toString())); + } + if (!snapshot.hasData) { + 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(16), + 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, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/views/empty_page.dart b/lib/views/empty_page.dart new file mode 100644 index 00000000..3e1794df --- /dev/null +++ b/lib/views/empty_page.dart @@ -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/logo.png', width: 100, height: 100), + ), + ); + } +}