From 6c5976f4a18a5fc29d7e027c64cf1abf8af7ab2b Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Sun, 26 Apr 2020 18:15:48 +0200 Subject: [PATCH] Implement status feature and new design --- CHANGELOG.md | 5 + .../list_items/presence_list_item.dart | 93 +++++ lib/components/matrix.dart | 2 +- lib/i18n/i18n.dart | 4 + lib/utils/client_presence_extension.dart | 11 + lib/utils/presence_extension.dart | 25 ++ lib/views/chat_list.dart | 363 ++++++++++-------- 7 files changed, 346 insertions(+), 157 deletions(-) create mode 100644 lib/components/list_items/presence_list_item.dart create mode 100644 lib/utils/client_presence_extension.dart create mode 100644 lib/utils/presence_extension.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea7d1e..f26d8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Version 0.13.0 - 2020-??-?? +### Features: +- New status feature +- Enhanced chat list design + # Version 0.12.4 - 2020-04-17 ### Fixed - Login without google services diff --git a/lib/components/list_items/presence_list_item.dart b/lib/components/list_items/presence_list_item.dart new file mode 100644 index 0000000..bf06964 --- /dev/null +++ b/lib/components/list_items/presence_list_item.dart @@ -0,0 +1,93 @@ +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:fluffychat/i18n/i18n.dart'; +import 'package:fluffychat/utils/app_route.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/views/chat.dart'; +import 'package:flutter/material.dart'; + +import '../avatar.dart'; +import '../matrix.dart'; +import 'package:fluffychat/utils/presence_extension.dart'; + +class PresenceListItem extends StatelessWidget { + final Presence presence; + + const PresenceListItem(this.presence); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Matrix.of(context).client.getProfileFromUserId(presence.sender), + builder: (context, snapshot) { + MxContent avatarUrl = MxContent(''); + String displayname = presence.sender.localpart; + if (snapshot.hasData) { + avatarUrl = snapshot.data.avatarUrl; + displayname = snapshot.data.displayname; + } + return InkWell( + onTap: () => showDialog( + context: context, + builder: (c) => AlertDialog( + title: ListTile( + contentPadding: EdgeInsets.zero, + leading: Avatar(avatarUrl, displayname), + title: Text(displayname), + subtitle: Text(presence.sender), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(presence.getLocalizedStatusMessage(context)), + Text( + presence.time.localizedTime(context), + style: TextStyle(fontSize: 12), + ), + ], + ), + actions: [ + if (presence.sender != Matrix.of(context).client.userID) + FlatButton( + child: Text(I18n.of(context).sendAMessage), + onPressed: () async { + final String roomId = await User( + presence.sender, + room: Room(id: '', client: Matrix.of(context).client), + ).startDirectChat(); + await Navigator.of(context).pushAndRemoveUntil( + AppRoute.defaultRoute( + context, + ChatView(roomId), + ), + (Route r) => r.isFirst); + }, + ), + FlatButton( + child: Text(I18n.of(context).close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + child: Container( + width: 80, + child: Column( + children: [ + SizedBox(height: 6), + Avatar(avatarUrl, displayname), + Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + displayname, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + ); + }); + } +} diff --git a/lib/components/matrix.dart b/lib/components/matrix.dart index 2481e8c..6ccdcef 100644 --- a/lib/components/matrix.dart +++ b/lib/components/matrix.dart @@ -406,7 +406,7 @@ class MatrixState extends State { void initState() { if (widget.client == null) { debugPrint("[Matrix] Init matrix client"); - client = Client(widget.clientName, debug: true); + client = Client(widget.clientName, debug: false); onJitsiCallSub ??= client.onEvent.stream .where((e) => e.type == 'timeline' && diff --git a/lib/i18n/i18n.dart b/lib/i18n/i18n.dart index ba81792..c09327d 100644 --- a/lib/i18n/i18n.dart +++ b/lib/i18n/i18n.dart @@ -624,6 +624,8 @@ class I18n { String get setInvitationLink => Intl.message("Set invitation link"); + String get setStatus => Intl.message('Set status'); + String get settings => Intl.message("Settings"); String get signUp => Intl.message("Sign up"); @@ -632,6 +634,8 @@ class I18n { String get systemTheme => Intl.message("System"); + String get statusExampleMessage => Intl.message("How are you today?"); + String get lightTheme => Intl.message("Light"); String get darkTheme => Intl.message("Dark"); diff --git a/lib/utils/client_presence_extension.dart b/lib/utils/client_presence_extension.dart new file mode 100644 index 0000000..d02cc30 --- /dev/null +++ b/lib/utils/client_presence_extension.dart @@ -0,0 +1,11 @@ +import 'package:famedlysdk/famedlysdk.dart'; +import 'presence_extension.dart'; + +extension ClientPresenceExtension on Client { + List get statusList { + final statusList = presences.values.toList(); + statusList.removeWhere((Presence p) => !p.isStatus); + statusList.sort((a, b) => b.time.compareTo(a.time)); + return statusList; + } +} diff --git a/lib/utils/presence_extension.dart b/lib/utils/presence_extension.dart new file mode 100644 index 0000000..f833620 --- /dev/null +++ b/lib/utils/presence_extension.dart @@ -0,0 +1,25 @@ +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:fluffychat/i18n/i18n.dart'; +import 'package:flutter/material.dart'; + +extension PresenceExtension on Presence { + bool get isStatus => + (statusMsg?.isNotEmpty ?? false) || + this.displayname != null || + this.avatarUrl != null; + + String getLocalizedStatusMessage(BuildContext context) { + if (!isStatus) return null; + if (statusMsg?.isNotEmpty ?? false) { + return statusMsg; + } + if (displayname != null) { + return I18n.of(context) + .changedTheDisplaynameTo(sender.localpart, displayname); + } + if (avatarUrl != null) { + return I18n.of(context).changedTheProfileAvatar(sender.localpart); + } + return null; + } +} diff --git a/lib/views/chat_list.dart b/lib/views/chat_list.dart index 888cec2..64284f1 100644 --- a/lib/views/chat_list.dart +++ b/lib/views/chat_list.dart @@ -2,13 +2,15 @@ import 'dart:async'; import 'dart:io'; import 'package:famedlysdk/famedlysdk.dart'; +import 'package:fluffychat/components/dialogs/simple_dialogs.dart'; +import 'package:fluffychat/components/list_items/presence_list_item.dart'; import 'package:fluffychat/components/list_items/public_room_list_item.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:share/share.dart'; -import '../components/dialogs/simple_dialogs.dart'; import '../components/theme_switcher.dart'; import '../components/adaptive_page_layout.dart'; import '../components/list_items/chat_list_item.dart'; @@ -16,6 +18,7 @@ import '../components/matrix.dart'; import '../i18n/i18n.dart'; import '../utils/app_route.dart'; import '../utils/url_launcher.dart'; +import '../utils/client_presence_extension.dart'; import 'archive.dart'; import 'new_group.dart'; import 'new_private_chat.dart'; @@ -48,7 +51,7 @@ class ChatList extends StatefulWidget { } class _ChatListState extends State { - bool searchMode = false; + bool get searchMode => searchController.text?.isNotEmpty ?? false; StreamSubscription sub; final TextEditingController searchController = TextEditingController(); SelectMode selectMode = SelectMode.normal; @@ -71,6 +74,13 @@ class _ChatListState extends State { void initState() { 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 = @@ -160,6 +170,39 @@ 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 status = await SimpleDialogs(context).enterText( + multiLine: true, + titleText: I18n.of(context).setStatus, + labelText: I18n.of(context).setStatus, + hintText: I18n.of(context).statusExampleMessage, + ); + if (status?.isEmpty ?? true) return; + await Matrix.of(context).tryRequestWithLoadingDialog( + Matrix.of(context).client.jsonRequest( + type: HTTPType.PUT, + action: + '/client/r0/presence/${Matrix.of(context).client.userID}/status', + data: { + "presence": "online", + "status_msg": status, + }, + ), + ); + } + @override void dispose() { sub?.cancel(); @@ -186,109 +229,102 @@ class _ChatListState extends State { setState(() => selectMode = SelectMode.normal); } return Scaffold( - appBar: AppBar( - title: searchMode - ? TextField( - autofocus: true, - autocorrect: false, - controller: searchController, - decoration: InputDecoration( - border: InputBorder.none, - hintText: I18n.of(context).searchForAChat, - ), - ) - : Text( - selectMode == SelectMode.share - ? I18n.of(context).share - : I18n.of(context).fluffychat, + drawer: Drawer( + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + ListTile( + leading: Icon(Icons.edit), + title: Text(I18n.of(context).setStatus), + onTap: () => _setStatus(context), ), - leading: searchMode - ? IconButton( - icon: Icon(Icons.arrow_back), - onPressed: () => setState(() { - publicRoomsResponse = null; - loadingPublicRooms = false; - searchMode = false; - }), - ) - : selectMode == SelectMode.share - ? IconButton( - icon: Icon(Icons.close), - onPressed: () { - Matrix.of(context).shareContent = null; - setState(() => selectMode = SelectMode.normal); - }, - ) - : null, - automaticallyImplyLeading: false, - actions: searchMode - ? [ - if (loadingPublicRooms) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - IconButton( - icon: Icon(Icons.domain), - onPressed: () async { - final String newSearchServer = await SimpleDialogs(context) - .enterText( - titleText: I18n.of(context).changeTheServer, - labelText: I18n.of(context).changeTheServer, - hintText: Matrix.of(context).client.userID.domain, - prefixText: "https://"); - if (newSearchServer?.isNotEmpty ?? false) { - searchServer = newSearchServer; - } - }, - ) - ] - : [ - IconButton( - icon: Icon(Icons.search), - onPressed: () => setState(() => searchMode = true), + Divider(height: 1), + ListTile( + leading: Icon(Icons.people_outline), + title: Text(I18n.of(context).createNewGroup), + onTap: () => _drawerTapAction(NewGroupView()), + ), + ListTile( + leading: Icon(Icons.person_add), + title: Text(I18n.of(context).newPrivateChat), + onTap: () => _drawerTapAction(NewPrivateChatView()), + ), + Divider(height: 1), + ListTile( + leading: Icon(Icons.archive), + title: Text(I18n.of(context).archive), + onTap: () => _drawerTapAction( + Archive(), ), - if (selectMode == SelectMode.normal) - PopupMenuButton( - onSelected: (String choice) { - switch (choice) { - case "settings": - Navigator.of(context).pushAndRemoveUntil( - AppRoute.defaultRoute( - context, - SettingsView(), - ), - (r) => r.isFirst, - ); - break; - case "archive": - Navigator.of(context).pushAndRemoveUntil( - AppRoute.defaultRoute( - context, - Archive(), - ), - (r) => r.isFirst, - ); - break; - } - }, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: "archive", - child: Text(I18n.of(context).archive), - ), - PopupMenuItem( - value: "settings", - child: Text(I18n.of(context).settings), - ), - ], - ), - ], + ), + ListTile( + leading: Icon(Icons.settings), + title: Text(I18n.of(context).settings), + onTap: () => _drawerTapAction( + SettingsView(), + ), + ), + Divider(height: 1), + ListTile( + leading: Icon(Icons.share), + title: Text(I18n.of(context).inviteContact), + onTap: () { + Navigator.of(context).pop(); + Share.share(I18n.of(context).inviteText( + Matrix.of(context).client.userID, + "https://matrix.to/#/${Matrix.of(context).client.userID}")); + }, + ), + ], + ), + ), ), - floatingActionButton: selectMode == SelectMode.share + appBar: AppBar( + elevation: 0, + titleSpacing: 6, + title: Container( + padding: EdgeInsets.all(8), + height: 42, + decoration: BoxDecoration( + color: Theme.of(context).secondaryHeaderColor, + borderRadius: BorderRadius.circular(90), + ), + child: TextField( + autocorrect: false, + controller: searchController, + decoration: InputDecoration( + suffixIcon: loadingPublicRooms + ? Container( + width: 20, + height: 20, + child: CircularProgressIndicator(), + ) + : Icon(Icons.search), + contentPadding: EdgeInsets.all(9), + border: InputBorder.none, + hintText: I18n.of(context).searchForAChat, + ), + ), + ), + bottom: Matrix.of(context).client.statusList.isEmpty + ? null + : PreferredSize( + preferredSize: Size.fromHeight(89), + child: Container( + height: 81, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: Matrix.of(context).client.statusList.length, + itemBuilder: (BuildContext context, int i) => + PresenceListItem( + Matrix.of(context).client.statusList[i]), + ), + ), + ), + ), + floatingActionButton: AdaptivePageLayout.columnMode(context) && + selectMode == SelectMode.share ? null : SpeedDial( child: Icon(Icons.add), @@ -318,61 +354,76 @@ class _ChatListState extends State { ), ], ), - body: FutureBuilder( - future: waitForFirstSync(context), - builder: (BuildContext context, snapshot) { - if (snapshot.hasData) { - List rooms = List.from(Matrix.of(context).client.rooms); - rooms.removeWhere((Room room) => - searchMode && - !room.displayname - .toLowerCase() - .contains(searchController.text.toLowerCase() ?? "")); - if (rooms.isEmpty && (!searchMode || publicRoomsResponse == null)) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - searchMode ? Icons.search : Icons.chat_bubble_outline, - size: 80, - color: Colors.grey, - ), - Text(searchMode - ? I18n.of(context).noRoomsFound - : I18n.of(context).startYourFirstChat), - ], - ), - ); - } - final int publicRoomsCount = - (publicRoomsResponse?.publicRooms?.length ?? 0); - final int totalCount = rooms.length + publicRoomsCount; - return ListView.separated( - separatorBuilder: (BuildContext context, int i) => - i == totalCount - publicRoomsCount - 1 - ? Material( - elevation: 2, - child: ListTile( - title: Text(I18n.of(context).publicRooms), + body: Column( + children: [ + Divider( + height: 1, + color: Theme.of(context).secondaryHeaderColor, + ), + Expanded( + child: FutureBuilder( + future: waitForFirstSync(context), + builder: (BuildContext context, snapshot) { + if (snapshot.hasData) { + List rooms = + List.from(Matrix.of(context).client.rooms); + rooms.removeWhere((Room room) => + searchMode && + !room.displayname + .toLowerCase() + .contains(searchController.text.toLowerCase() ?? "")); + if (rooms.isEmpty && + (!searchMode || publicRoomsResponse == null)) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + searchMode + ? Icons.search + : Icons.chat_bubble_outline, + size: 80, + color: Colors.grey, ), - ) - : Container(), - itemCount: totalCount, - itemBuilder: (BuildContext context, int i) => i < rooms.length - ? ChatListItem( - rooms[i], - activeChat: widget.activeChat == rooms[i].id, - ) - : PublicRoomListItem( - publicRoomsResponse.publicRooms[i - rooms.length]), - ); - } else { - return Center( - child: CircularProgressIndicator(), - ); - } - }, + Text(searchMode + ? I18n.of(context).noRoomsFound + : I18n.of(context).startYourFirstChat), + ], + ), + ); + } + final int publicRoomsCount = + (publicRoomsResponse?.publicRooms?.length ?? 0); + final int totalCount = rooms.length + publicRoomsCount; + return ListView.separated( + separatorBuilder: (BuildContext context, int i) => + i == totalCount - publicRoomsCount - 1 + ? Material( + elevation: 2, + child: ListTile( + title: Text(I18n.of(context).publicRooms), + ), + ) + : Container(), + itemCount: totalCount, + itemBuilder: (BuildContext context, int i) => i < + rooms.length + ? ChatListItem( + rooms[i], + activeChat: widget.activeChat == rooms[i].id, + ) + : PublicRoomListItem( + publicRoomsResponse.publicRooms[i - rooms.length]), + ); + } else { + return Center( + child: CircularProgressIndicator(), + ); + } + }, + ), + ), + ], ), ); }