feat: Implement discover groups page

This commit is contained in:
Christian Pauly 2020-12-06 12:51:40 +01:00
parent adb445f668
commit e728ccc1ba
8 changed files with 443 additions and 218 deletions

View File

@ -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,
),
),
),
);
}
}

View File

@ -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: <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, 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(),
),
),
],
),
),
);
}
}

View File

@ -1370,6 +1370,16 @@
"count": {} "count": {}
} }
}, },
"discoverGroups": "Discover groups",
"@discoverGroups": {
"type": "text",
"placeholders": {}
},
"noDescription": "No description",
"@noDescription": {
"type": "text",
"placeholders": {}
},
"editBlockedServers": "Edit blocked servers", "editBlockedServers": "Edit blocked servers",
"@editBlockedServers": { "@editBlockedServers": {
"type": "text", "type": "text",

View File

@ -13,6 +13,9 @@ extension RoomStatusExtension on Room {
directChatPresence.presence != null && directChatPresence.presence != null &&
(directChatPresence.presence.lastActiveAgo != null || (directChatPresence.presence.lastActiveAgo != null ||
directChatPresence.presence.currentlyActive != null)) { directChatPresence.presence.currentlyActive != null)) {
if (directChatPresence.presence.statusMsg?.isNotEmpty ?? false) {
return directChatPresence.presence.statusMsg;
}
if (directChatPresence.presence.currentlyActive == true) { if (directChatPresence.presence.currentlyActive == true) {
return L10n.of(context).currentlyActive; return L10n.of(context).currentlyActive;
} }

View File

@ -18,12 +18,28 @@ class _ArchiveState extends State<Archive> {
return await Matrix.of(context).client.archive; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AdaptivePageLayout( return AdaptivePageLayout(
firstScaffold: Scaffold( firstScaffold: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(L10n.of(context).archive), title: Text(L10n.of(context).archive),
elevation: _scrolledToTop ? 0 : null,
), ),
body: FutureBuilder<List<Room>>( body: FutureBuilder<List<Room>>(
future: getArchive(context), future: getArchive(context),
@ -33,6 +49,7 @@ class _ArchiveState extends State<Archive> {
} else { } else {
archive = snapshot.data; archive = snapshot.data;
return ListView.builder( return ListView.builder(
controller: _scrollController,
itemCount: archive.length, itemCount: archive.length,
itemBuilder: (BuildContext context, int i) => ChatListItem( itemBuilder: (BuildContext context, int i) => ChatListItem(
archive[i], archive[i],

View File

@ -3,12 +3,11 @@ import 'dart:io';
import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/matrix_api.dart';
import 'package:fluffychat/components/connection_status_header.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/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/config/app_config.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -21,11 +20,9 @@ import '../components/matrix.dart';
import '../utils/app_route.dart'; import '../utils/app_route.dart';
import '../utils/matrix_file_extension.dart'; import '../utils/matrix_file_extension.dart';
import '../utils/url_launcher.dart'; import '../utils/url_launcher.dart';
import 'archive.dart'; import 'empty_page.dart';
import 'homeserver_picker.dart'; import 'homeserver_picker.dart';
import 'new_group.dart';
import 'new_private_chat.dart'; import 'new_private_chat.dart';
import 'settings.dart';
enum SelectMode { normal, share, select } enum SelectMode { normal, share, select }
@ -35,11 +32,7 @@ class ChatListView extends StatelessWidget {
return AdaptivePageLayout( return AdaptivePageLayout(
primaryPage: FocusPage.FIRST, primaryPage: FocusPage.FIRST,
firstScaffold: ChatList(), firstScaffold: ChatList(),
secondScaffold: Scaffold( secondScaffold: EmptyPage(),
body: Center(
child: Image.asset('assets/logo.png', width: 100, height: 100),
),
),
); );
} }
} }
@ -56,14 +49,10 @@ class ChatList extends StatefulWidget {
class _ChatListState extends State<ChatList> { class _ChatListState extends State<ChatList> {
bool get searchMode => searchController.text?.isNotEmpty ?? false; bool get searchMode => searchController.text?.isNotEmpty ?? false;
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
Timer coolDown;
PublicRoomsResponse publicRoomsResponse;
bool loadingPublicRooms = false;
String searchServer;
final _selectedRoomIds = <String>{}; final _selectedRoomIds = <String>{};
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _scrolledToTop = true;
void _toggleSelection(String roomId) => void _toggleSelection(String roomId) =>
setState(() => _selectedRoomIds.contains(roomId) setState(() => _selectedRoomIds.contains(roomId)
@ -78,8 +67,6 @@ class _ChatListState extends State<ChatList> {
return true; return true;
} }
bool _scrolledToTop = true;
@override @override
void initState() { void initState() {
_scrollController.addListener(() async { _scrollController.addListener(() async {
@ -89,46 +76,6 @@ class _ChatListState extends State<ChatList> {
setState(() => _scrolledToTop = true); 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(); _initReceiveSharingIntent();
super.initState(); super.initState();
} }
@ -186,45 +133,8 @@ class _ChatListState extends State<ChatList> {
ReceiveSharingIntent.getInitialText().then(_processIncomingSharedText); 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 @override
void dispose() { void dispose() {
searchController.removeListener(
() => setState(() => null),
);
_intentDataStreamSubscription?.cancel(); _intentDataStreamSubscription?.cancel();
_intentFileStreamSubscription?.cancel(); _intentFileStreamSubscription?.cancel();
super.dispose(); super.dispose();
@ -292,62 +202,8 @@ class _ChatListState extends State<ChatList> {
_selectedRoomIds.clear(); _selectedRoomIds.clear();
} }
return Scaffold( return Scaffold(
drawer: selectMode != SelectMode.normal drawer:
? null selectMode != SelectMode.normal ? null : DefaultDrawer(),
: 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(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);
},
),
],
),
),
),
appBar: AppBar( appBar: AppBar(
centerTitle: false, centerTitle: false,
elevation: _scrolledToTop ? 0 : null, elevation: _scrolledToTop ? 0 : null,
@ -387,39 +243,9 @@ class _ChatListState extends State<ChatList> {
? Text(L10n.of(context).share) ? Text(L10n.of(context).share)
: selectMode == SelectMode.select : selectMode == SelectMode.select
? Text(_selectedRoomIds.length.toString()) ? Text(_selectedRoomIds.length.toString())
: Container( : DefaultAppBarSearchField(
height: 40, searchController: searchController,
padding: EdgeInsets.only(right: 8), onChanged: (_) => setState(() => null),
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,
),
),
),
), ),
), ),
floatingActionButton: AdaptivePageLayout.columnMode(context) floatingActionButton: AdaptivePageLayout.columnMode(context)
@ -463,9 +289,7 @@ class _ChatListState extends State<ChatList> {
.contains(searchController.text .contains(searchController.text
.toLowerCase() ?? .toLowerCase() ??
''))); '')));
if (rooms.isEmpty && if (rooms.isEmpty && (!searchMode)) {
(!searchMode ||
publicRoomsResponse == null)) {
return Center( return Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -485,16 +309,12 @@ class _ChatListState extends State<ChatList> {
), ),
); );
} }
final publicRoomsCount = final totalCount = rooms.length;
(publicRoomsResponse?.chunk?.length ??
0);
final totalCount =
rooms.length + publicRoomsCount;
return ListView.separated( return ListView.separated(
controller: _scrollController, controller: _scrollController,
separatorBuilder: (BuildContext context, separatorBuilder: (BuildContext context,
int i) => int i) =>
i == totalCount - publicRoomsCount i == totalCount
? ListTile( ? ListTile(
title: Text( title: Text(
L10n.of(context) L10n.of(context)
@ -510,31 +330,24 @@ class _ChatListState extends State<ChatList> {
) )
: Container(), : Container(),
itemCount: totalCount, itemCount: totalCount,
itemBuilder: (BuildContext context, itemBuilder:
int i) => (BuildContext context, int i) =>
i < rooms.length ChatListItem(
? ChatListItem( rooms[i],
rooms[i], selected: _selectedRoomIds
selected: _selectedRoomIds .contains(rooms[i].id),
.contains(rooms[i].id), onTap: selectMode == SelectMode.select
onTap: selectMode == ? () =>
SelectMode.select _toggleSelection(rooms[i].id)
? () => _toggleSelection( : null,
rooms[i].id) onLongPress: selectMode !=
: null, SelectMode.share
onLongPress: selectMode != ? () =>
SelectMode.share _toggleSelection(rooms[i].id)
? () => _toggleSelection( : null,
rooms[i].id) activeChat:
: null, widget.activeChat == rooms[i].id,
activeChat: ),
widget.activeChat ==
rooms[i].id,
)
: PublicRoomListItem(
publicRoomsResponse
.chunk[i - rooms.length],
),
); );
} else { } else {
return Center( return Center(

View File

@ -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<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) 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<PublicRoomsResponse>(
future: _publicRoomsResponse,
builder: (BuildContext context,
AsyncSnapshot<PublicRoomsResponse> 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,
),
],
),
),
),
),
);
},
),
);
}
}

12
lib/views/empty_page.dart Normal file
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/logo.png', width: 100, height: 100),
),
);
}
}