import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.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/search_title.dart'; import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; import '../../utils/localized_exception_extension.dart'; import '../../widgets/matrix.dart'; import 'chat_list_header.dart'; class SpaceView extends StatefulWidget { final ChatListController controller; final ScrollController scrollController; const SpaceView( this.controller, { super.key, required this.scrollController, }); @override State createState() => _SpaceViewState(); } class _SpaceViewState extends State { static final Map _lastResponse = {}; String? prevBatch; Object? error; bool loading = false; @override void initState() { loadHierarchy(); super.initState(); } void _refresh() { _lastResponse.remove(widget.controller.activeSpaceId); loadHierarchy(); } Future loadHierarchy([String? prevBatch]) async { final activeSpaceId = widget.controller.activeSpaceId!; final client = Matrix.of(context).client; final activeSpace = client.getRoomById(activeSpaceId); await activeSpace?.postLoad(); setState(() { error = null; loading = true; }); try { final response = await client.getSpaceHierarchy( activeSpaceId, maxDepth: 1, from: prevBatch, ); if (prevBatch != null) { response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []); } setState(() { _lastResponse[activeSpaceId] = response; }); return _lastResponse[activeSpaceId]!; } catch (e) { setState(() { error = e; }); rethrow; } finally { setState(() { loading = false; }); } } void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async { final client = Matrix.of(context).client; final space = client.getRoomById(widget.controller.activeSpaceId!); if (client.getRoomById(spaceChild.roomId) == null) { final result = await showFutureLoadingDialog( context: context, future: () async { await client.joinRoom( spaceChild.roomId, serverName: space?.spaceChildren .firstWhereOrNull( (child) => child.roomId == spaceChild.roomId, ) ?.via, ); if (client.getRoomById(spaceChild.roomId) == null) { // Wait for room actually appears in sync await client.waitForRoomInSync(spaceChild.roomId, join: true); } }, ); if (result.error != null) return; _refresh(); } if (spaceChild.roomType == 'm.space') { if (spaceChild.roomId == widget.controller.activeSpaceId) { context.go('/rooms/${spaceChild.roomId}'); } else { widget.controller.setActiveSpace(spaceChild.roomId); } return; } context.go('/rooms/${spaceChild.roomId}'); } void _onSpaceChildContextMenu([ SpaceRoomsChunk? spaceChild, Room? room, ]) async { final client = Matrix.of(context).client; final activeSpaceId = widget.controller.activeSpaceId; final activeSpace = activeSpaceId == null ? null : client.getRoomById(activeSpaceId); final action = await showModalActionSheet( context: context, title: spaceChild?.name ?? room?.getLocalizedDisplayname( MatrixLocals(L10n.of(context)!), ), message: spaceChild?.topic ?? room?.topic, actions: [ if (room == null) SheetAction( key: SpaceChildContextAction.join, label: L10n.of(context)!.joinRoom, icon: Icons.send_outlined, ), if (spaceChild != null && (activeSpace?.canChangeStateEvent(EventTypes.spaceChild) ?? false)) SheetAction( key: SpaceChildContextAction.removeFromSpace, label: L10n.of(context)!.removeFromSpace, icon: Icons.delete_sweep_outlined, ), if (room != null) SheetAction( key: SpaceChildContextAction.leave, label: L10n.of(context)!.leave, icon: Icons.delete_outlined, isDestructiveAction: true, ), ], ); if (action == null) return; switch (action) { case SpaceChildContextAction.join: _onJoinSpaceChild(spaceChild!); break; case SpaceChildContextAction.leave: await showFutureLoadingDialog( context: context, future: room!.leave, ); break; case SpaceChildContextAction.removeFromSpace: await showFutureLoadingDialog( context: context, future: () => activeSpace!.removeSpaceChild(spaceChild!.roomId), ); break; } } void _addChatOrSubSpace() async { final roomType = await showConfirmationDialog( context: context, title: L10n.of(context)!.addChatOrSubSpace, actions: [ AlertDialogAction( key: AddRoomType.subspace, label: L10n.of(context)!.createNewSpace, ), AlertDialogAction( key: AddRoomType.chat, label: L10n.of(context)!.createGroup, ), ], ); if (roomType == null) return; final names = await showTextInputDialog( context: context, title: roomType == AddRoomType.subspace ? L10n.of(context)!.createNewSpace : L10n.of(context)!.createGroup, textFields: [ DialogTextField( hintText: roomType == AddRoomType.subspace ? L10n.of(context)!.spaceName : L10n.of(context)!.groupName, minLines: 1, maxLines: 1, maxLength: 64, validator: (text) { if (text == null || text.isEmpty) { return L10n.of(context)!.pleaseChoose; } return null; }, ), DialogTextField( hintText: L10n.of(context)!.chatDescription, minLines: 4, maxLines: 8, maxLength: 255, ), ], okLabel: L10n.of(context)!.create, cancelLabel: L10n.of(context)!.cancel, ); if (names == null) return; final client = Matrix.of(context).client; final result = await showFutureLoadingDialog( context: context, future: () async { late final String roomId; final activeSpace = client.getRoomById( widget.controller.activeSpaceId!, )!; if (roomType == AddRoomType.subspace) { roomId = await client.createSpace( name: names.first, topic: names.last.isEmpty ? null : names.last, visibility: activeSpace.joinRules == JoinRules.public ? sdk.Visibility.public : sdk.Visibility.private, ); } else { roomId = await client.createGroupChat( groupName: names.first, initialState: names.length > 1 && names.last.isNotEmpty ? [ sdk.StateEvent( type: sdk.EventTypes.RoomTopic, content: {'topic': names.last}, ), ] : null, ); } await activeSpace.setSpaceChild(roomId); }, ); if (result.error != null) return; _refresh(); } @override Widget build(BuildContext context) { final client = Matrix.of(context).client; final activeSpaceId = widget.controller.activeSpaceId; final activeSpace = activeSpaceId == null ? null : client.getRoomById( activeSpaceId, ); final allSpaces = client.rooms.where((room) => room.isSpace); if (activeSpaceId == null) { final rootSpaces = allSpaces .where( (space) => !allSpaces.any( (parentSpace) => parentSpace.spaceChildren .any((child) => child.roomId == space.id), ), ) .toList(); return SafeArea( child: CustomScrollView( controller: widget.scrollController, slivers: [ ChatListHeader(controller: widget.controller), SliverList( delegate: SliverChildBuilderDelegate( (context, i) { final rootSpace = rootSpaces[i]; final displayname = rootSpace.getLocalizedDisplayname( MatrixLocals(L10n.of(context)!), ); return Material( color: Theme.of(context).colorScheme.background, child: ListTile( leading: Avatar( mxContent: rootSpace.avatar, name: displayname, ), title: Text( displayname, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( L10n.of(context)!.numChats( rootSpace.spaceChildren.length.toString(), ), ), onTap: () => widget.controller.setActiveSpace(rootSpace.id), onLongPress: () => _onSpaceChildContextMenu(null, rootSpace), trailing: const Icon(Icons.chevron_right_outlined), ), ); }, childCount: rootSpaces.length, ), ), ], ), ); } final parentSpace = allSpaces.firstWhereOrNull( (space) => space.spaceChildren.any((child) => child.roomId == activeSpaceId), ); return PopScope( canPop: parentSpace == null, onPopInvoked: (pop) async { if (pop) return; if (parentSpace != null) { widget.controller.setActiveSpace(parentSpace.id); } }, child: SafeArea( child: CustomScrollView( controller: widget.scrollController, slivers: [ ChatListHeader(controller: widget.controller), SliverAppBar( automaticallyImplyLeading: false, primary: false, titleSpacing: 0, title: ListTile( leading: BackButton( onPressed: () => widget.controller.setActiveSpace(parentSpace?.id), ), title: Text( parentSpace == null ? L10n.of(context)!.allSpaces : parentSpace.getLocalizedDisplayname( MatrixLocals(L10n.of(context)!), ), ), trailing: IconButton( icon: loading ? const CircularProgressIndicator.adaptive(strokeWidth: 2) : const Icon(Icons.refresh_outlined), onPressed: loading ? null : _refresh, ), ), ), Builder( builder: (context) { final response = _lastResponse[activeSpaceId]; final error = this.error; if (error != null) { return SliverFillRemaining( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.all(16.0), child: Text(error.toLocalizedString(context)), ), IconButton( onPressed: _refresh, icon: const Icon(Icons.refresh_outlined), ), ], ), ); } if (response == null) { return SliverFillRemaining( child: Center( child: Text(L10n.of(context)!.loadingPleaseWait), ), ); } final spaceChildren = response.rooms; final canLoadMore = response.nextBatch != null; return SliverList( delegate: SliverChildBuilderDelegate( (context, i) { if (canLoadMore && i == spaceChildren.length) { return Padding( padding: const EdgeInsets.all(16.0), child: OutlinedButton.icon( label: loading ? const LinearProgressIndicator() : Text(L10n.of(context)!.loadMore), icon: const Icon(Icons.chevron_right_outlined), onPressed: loading ? null : () { loadHierarchy(response.nextBatch); }, ), ); } final spaceChild = spaceChildren[i]; final room = client.getRoomById(spaceChild.roomId); if (room != null && !room.isSpace) { return ChatListItem( room, onLongPress: () => _onSpaceChildContextMenu(spaceChild, room), activeChat: widget.controller.activeChat == room.id, onTap: () => onChatTap(room, context), ); } final isSpace = spaceChild.roomType == 'm.space'; final topic = spaceChild.topic?.isEmpty ?? true ? null : spaceChild.topic; if (spaceChild.roomId == activeSpaceId) { return Column( mainAxisSize: MainAxisSize.min, children: [ SearchTitle( title: spaceChild.name ?? spaceChild.canonicalAlias ?? 'Space', icon: Padding( padding: const EdgeInsets.symmetric( horizontal: 10.0, ), child: Avatar( size: 24, mxContent: spaceChild.avatarUrl, name: spaceChild.name, fontSize: 9, ), ), color: Theme.of(context) .colorScheme .secondaryContainer .withAlpha(128), trailing: const Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: Icon(Icons.edit_outlined), ), onTap: () => _onJoinSpaceChild(spaceChild), ), if (activeSpace?.canChangeStateEvent( EventTypes.spaceChild, ) == true) Material( child: ListTile( leading: const CircleAvatar( child: Icon(Icons.group_add_outlined), ), title: Text(L10n.of(context)!.addChatOrSubSpace), trailing: const Icon(Icons.chevron_right_outlined), onTap: _addChatOrSubSpace, ), ), ], ); } final name = spaceChild.name ?? spaceChild.canonicalAlias ?? L10n.of(context)!.chat; if (widget.controller.isSearchMode && !name.toLowerCase().contains( widget.controller.searchController.text, )) { return const SizedBox.shrink(); } return Material( child: ListTile( leading: Avatar( mxContent: spaceChild.avatarUrl, name: spaceChild.name, ), title: Row( children: [ Expanded( child: Text( name, maxLines: 1, style: const TextStyle( fontWeight: FontWeight.bold, ), ), ), if (!isSpace) ...[ const Icon( Icons.people_outline, size: 16, ), const SizedBox(width: 4), Text( spaceChild.numJoinedMembers.toString(), style: const TextStyle(fontSize: 14), ), ], ], ), onTap: () => room?.isSpace == true ? widget.controller.setActiveSpace(room!.id) : _onSpaceChildContextMenu(spaceChild, room), onLongPress: () => _onSpaceChildContextMenu(spaceChild, room), subtitle: Text( topic ?? (isSpace ? L10n.of(context)!.enterSpace : L10n.of(context)!.enterRoom), maxLines: 1, style: TextStyle( color: Theme.of(context).colorScheme.onBackground, ), ), trailing: isSpace ? const Icon(Icons.chevron_right_outlined) : null, ), ); }, childCount: spaceChildren.length + (canLoadMore ? 1 : 0), ), ); }, ), ], ), ), ); } } enum SpaceChildContextAction { join, leave, removeFromSpace, } enum AddRoomType { chat, subspace }