import 'dart:async'; import 'dart:math'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/client_chooser_button.dart'; import 'package:fluffychat/pages/chat_list/spaces_bottom_bar.dart'; import 'package:fluffychat/pages/chat_list/stories_header.dart'; import 'package:fluffychat/widgets/connection_status_header.dart'; import '../../utils/stream_extension.dart'; import '../../widgets/matrix.dart'; class ChatListView extends StatelessWidget { final ChatListController controller; const ChatListView(this.controller, {Key key}) : super(key: key); @override Widget build(BuildContext context) { return StreamBuilder( stream: Matrix.of(context).onShareContentChanged.stream, builder: (_, __) { final selectMode = controller.selectMode; return VWidgetGuard( onSystemPop: (redirector) async { final selMode = controller.selectMode; if (selMode != SelectMode.normal) controller.cancelAction(); if (selMode == SelectMode.select) redirector.stopRedirection(); }, child: Scaffold( appBar: AppBar( elevation: controller.scrolledToTop ? 0 : null, actionsIconTheme: IconThemeData( color: controller.selectedRoomIds.isEmpty ? null : Theme.of(context).colorScheme.primary, ), leading: selectMode == SelectMode.normal ? Matrix.of(context).isMultiAccount ? ClientChooserButton(controller) : null : IconButton( tooltip: L10n.of(context).cancel, icon: const Icon(Icons.close_outlined), onPressed: controller.cancelAction, color: Theme.of(context).colorScheme.primary, ), centerTitle: false, actions: selectMode == SelectMode.share ? null : selectMode == SelectMode.select ? [ if (controller.spaces.isNotEmpty) IconButton( tooltip: L10n.of(context).addToSpace, icon: const Icon(Icons.group_work_outlined), onPressed: controller.addOrRemoveToSpace, ), IconButton( tooltip: L10n.of(context).toggleUnread, icon: Icon( controller.anySelectedRoomNotMarkedUnread ? Icons.mark_chat_read_outlined : Icons.mark_chat_unread_outlined), onPressed: controller.toggleUnread, ), IconButton( tooltip: L10n.of(context).toggleFavorite, icon: Icon(controller.anySelectedRoomNotFavorite ? Icons.push_pin_outlined : Icons.push_pin), onPressed: controller.toggleFavouriteRoom, ), IconButton( icon: Icon(controller.anySelectedRoomNotMuted ? Icons.notifications_off_outlined : Icons.notifications_outlined), tooltip: L10n.of(context).toggleMuted, onPressed: controller.toggleMuted, ), IconButton( icon: const Icon(Icons.delete_outlined), tooltip: L10n.of(context).archive, onPressed: controller.archiveAction, ), ] : [ IconButton( icon: const Icon(Icons.search_outlined), tooltip: L10n.of(context).search, onPressed: () => VRouter.of(context).to('/search'), ), PopupMenuButton( onSelected: controller.onPopupMenuSelect, itemBuilder: (_) => [ PopupMenuItem( value: PopupMenuAction.setStatus, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.edit_outlined), const SizedBox(width: 12), Text(L10n.of(context).setStatus), ], ), ), PopupMenuItem( value: PopupMenuAction.newGroup, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.group_add_outlined), const SizedBox(width: 12), Text(L10n.of(context).createNewGroup), ], ), ), PopupMenuItem( value: PopupMenuAction.newSpace, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.group_work_outlined), const SizedBox(width: 12), Text(L10n.of(context).createNewSpace), ], ), ), PopupMenuItem( value: PopupMenuAction.invite, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.share_outlined), const SizedBox(width: 12), Text(L10n.of(context).inviteContact), ], ), ), PopupMenuItem( value: PopupMenuAction.archive, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.archive_outlined), const SizedBox(width: 12), Text(L10n.of(context).archive), ], ), ), PopupMenuItem( value: PopupMenuAction.settings, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.settings_outlined), const SizedBox(width: 12), Text(L10n.of(context).settings), ], ), ), ], ), ], title: Text(selectMode == SelectMode.share ? L10n.of(context).share : selectMode == SelectMode.select ? controller.selectedRoomIds.length.toString() : controller.activeSpaceId == null ? AppConfig.applicationName : Matrix.of(context) .client .getRoomById(controller.activeSpaceId) .displayname), ), body: Column(children: [ AnimatedContainer( height: controller.showChatBackupBanner ? 54 : 0, duration: const Duration(milliseconds: 300), clipBehavior: Clip.hardEdge, curve: Curves.bounceInOut, decoration: const BoxDecoration(), child: Material( color: Theme.of(context).colorScheme.surface, child: ListTile( leading: Image.asset( 'assets/backup.png', fit: BoxFit.contain, width: 44, ), title: Text(L10n.of(context).setupChatBackupNow), trailing: const Icon(Icons.chevron_right_outlined), onTap: controller.firstRunBootstrapAction, ), ), ), Expanded(child: _ChatListViewBody(controller)), ]), floatingActionButton: selectMode == SelectMode.normal ? controller.scrolledToTop ? FloatingActionButton.extended( onPressed: () => VRouter.of(context).to('/newprivatechat'), icon: const Icon(CupertinoIcons.chat_bubble), label: Text(L10n.of(context).newChat), ) : FloatingActionButton( onPressed: () => VRouter.of(context).to('/newprivatechat'), child: const Icon(CupertinoIcons.chat_bubble), ) : null, bottomNavigationBar: Column( mainAxisSize: MainAxisSize.min, children: [ const ConnectionStatusHeader(), if (controller.spaces.isNotEmpty && controller.selectedRoomIds.isEmpty) SpacesBottomBar(controller), ], ), ), ); }); } } class _ChatListViewBody extends StatefulWidget { final ChatListController controller; const _ChatListViewBody(this.controller, {Key key}) : super(key: key); @override State<_ChatListViewBody> createState() => _ChatListViewBodyState(); } class _ChatListViewBodyState extends State<_ChatListViewBody> { // the matrix sync stream StreamSubscription _subscription; StreamSubscription _clientSubscription; // used to check the animation direction String _lastUserId; String _lastSpaceId; @override void initState() { _subscription = Matrix.of(context) .client .onSync .stream .where((s) => s.hasRoomUpdate) .rateLimit(const Duration(seconds: 1)) .listen((d) => setState(() {})); _clientSubscription = widget.controller.clientStream.listen((d) => setState(() {})); super.initState(); } @override Widget build(BuildContext context) { final reversed = _animationReversed(); Widget child; if (widget.controller.waitForFirstSync && Matrix.of(context).client.prevBatch != null) { final rooms = Matrix.of(context) .client .rooms .where(widget.controller.roomCheck) .toList(); if (rooms.isEmpty) { child = Column( key: const ValueKey(null), mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.maps_ugc_outlined, size: 80, color: Colors.grey, ), Center( child: Text( L10n.of(context).startYourFirstChat, textAlign: TextAlign.start, style: const TextStyle( color: Colors.grey, fontSize: 16, ), ), ), ], ); } final displayStoriesHeader = widget.controller.activeSpaceId == null; child = ListView.builder( key: ValueKey(Matrix.of(context).client.userID.toString() + widget.controller.activeSpaceId.toString()), controller: widget.controller.scrollController, itemCount: rooms.length + (displayStoriesHeader ? 1 : 0), itemBuilder: (BuildContext context, int i) { if (i == 0 && displayStoriesHeader) { return const StoriesHeader(); } i--; return ChatListItem( rooms[i], selected: widget.controller.selectedRoomIds.contains(rooms[i].id), onTap: widget.controller.selectMode == SelectMode.select ? () => widget.controller.toggleSelection(rooms[i].id) : null, onLongPress: () => widget.controller.toggleSelection(rooms[i].id), activeChat: widget.controller.activeChat == rooms[i].id, ); }, ); } else { child = ListView( key: const ValueKey(false), children: List.filled( 8, const _DummyChat(), ), ); } child = Material( color: Theme.of(context).scaffoldBackgroundColor, child: child, ); return PageTransitionSwitcher( reverse: reversed, transitionBuilder: ( Widget child, Animation primaryAnimation, Animation secondaryAnimation, ) { return SharedAxisTransition( animation: primaryAnimation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: child, ); }, child: child, ); } @override void dispose() { _subscription.cancel(); _clientSubscription.cancel(); super.dispose(); } bool _animationReversed() { bool reversed; // in case the matrix id changes, check the indexOf the matrix id final newClient = Matrix.of(context).client; if (_lastUserId != newClient.userID) { reversed = Matrix.of(context) .currentBundle .indexWhere((element) => element.userID == _lastUserId) < Matrix.of(context) .currentBundle .indexWhere((element) => element.userID == newClient.userID); } // otherwise, the space changed... else { reversed = widget.controller.spaces .indexWhere((element) => element.id == _lastSpaceId) < widget.controller.spaces.indexWhere( (element) => element.id == widget.controller.activeSpaceId); } _lastUserId = newClient.userID; _lastSpaceId = widget.controller.activeSpaceId; return reversed; } } class _DummyChat extends StatefulWidget { const _DummyChat({Key key}) : super(key: key); @override State<_DummyChat> createState() => _DummyChatState(); } class _DummyChatState extends State<_DummyChat> { double opacity = Random().nextDouble(); Timer _timer; static const duration = Duration(seconds: 1); void _setRandomOpacity(_) { if (mounted) { setState(() => opacity = Random().nextDouble()); } } @override void initState() { WidgetsBinding.instance.addPostFrameCallback(_setRandomOpacity); _timer ??= Timer.periodic(duration, _setRandomOpacity); super.initState(); } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final titleColor = Theme.of(context).textTheme.bodyText1.color.withAlpha(100); final subtitleColor = Theme.of(context).textTheme.bodyText1.color.withAlpha(50); return AnimatedOpacity( opacity: opacity, duration: duration, child: ListTile( leading: CircleAvatar( backgroundColor: titleColor, child: CircularProgressIndicator( strokeWidth: 1, color: Theme.of(context).textTheme.bodyText1.color, ), ), title: Row( children: [ Expanded( child: Container( height: 14, decoration: BoxDecoration( color: titleColor, borderRadius: BorderRadius.circular(3), ), ), ), const SizedBox(width: 36), Container( height: 14, width: 14, decoration: BoxDecoration( color: subtitleColor, borderRadius: BorderRadius.circular(14), ), ), const SizedBox(width: 12), Container( height: 14, width: 14, decoration: BoxDecoration( color: subtitleColor, borderRadius: BorderRadius.circular(14), ), ), ], ), subtitle: Container( decoration: BoxDecoration( color: subtitleColor, borderRadius: BorderRadius.circular(3), ), height: 12, margin: const EdgeInsets.only(right: 22), ), ), ); } } enum ChatListPopupMenuItemActions { createGroup, createSpace, discover, setStatus, inviteContact, settings, }