import 'dart:async'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/conversation_bot/conversation_bot_settings.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart'; import 'package:fluffychat/pangea/spaces/widgets/leaderboard_participant_list.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; class PangeaChatDetailsView extends StatelessWidget { final ChatDetailsController controller; const PangeaChatDetailsView(this.controller, {super.key}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final room = Matrix.of(context).client.getRoomById(controller.roomId!); if (room == null || room.membership == Membership.leave) { return Scaffold( appBar: AppBar( title: Text(L10n.of(context).oopsSomethingWentWrong), ), body: Center( child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat), ), ); } return StreamBuilder( stream: room.client.onRoomState.stream .where((update) => update.roomId == room.id), builder: (context, snapshot) { var members = room.getParticipants().toList() ..sort((b, a) => a.powerLevel.compareTo(b.powerLevel)); members = members.take(10).toList(); final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) + (room.summary.mJoinedMemberCount ?? 0); final displayname = room.getLocalizedDisplayname( MatrixLocals(L10n.of(context)), ); return Scaffold( appBar: AppBar( leading: controller.widget.embeddedCloseButton ?? (room.isSpace ? FluffyThemes.isColumnMode(context) ? const SizedBox() : BackButton( onPressed: () => context.go("/rooms?spaceId=${room.id}"), ) : const Center(child: BackButton())), ), body: MaxWidthBody( maxWidth: 900, showBorder: false, child: ListView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: 2, itemBuilder: (BuildContext context, int i) => i == 0 ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Padding( padding: const EdgeInsets.all(32.0), child: Stack( children: [ Hero( tag: controller.widget.embeddedCloseButton != null ? 'embedded_content_banner' : 'content_banner', child: Avatar( mxContent: room.avatar, name: displayname, userId: room.directChatMatrixID, size: Avatar.defaultSize * 2.5, borderRadius: room.isSpace ? BorderRadius.circular(24.0) : null, ), ), if (!room.isDirectChat && room.canChangeStateEvent( EventTypes.RoomAvatar, )) Positioned( bottom: 0, right: 0, child: FloatingActionButton.small( onPressed: controller.setAvatarAction, heroTag: null, child: const Icon( Icons.camera_alt_outlined, ), ), ), ], ), ), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextButton.icon( onPressed: () => room.isDirectChat ? null : room.canChangeStateEvent( EventTypes.RoomName, ) ? controller.setDisplaynameAction() : FluffyShare.share( displayname, context, copyOnly: true, ), icon: Icon( room.isDirectChat ? Icons.chat_bubble_outline : room.canChangeStateEvent( EventTypes.RoomName, ) ? Icons.edit_outlined : Icons.copy_outlined, size: 16, ), style: TextButton.styleFrom( foregroundColor: theme.colorScheme.onSurface, ), label: Text( room.isDirectChat ? L10n.of(context).directChat : displayname, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 18), ), ), TextButton.icon( onPressed: () => room.isDirectChat ? null : context.push( '/rooms/${controller.roomId}/details/members', ), icon: const Icon( Icons.group_outlined, size: 14, ), style: TextButton.styleFrom( foregroundColor: theme.colorScheme.secondary, ), label: Text( L10n.of(context).countParticipants( actualMembersCount, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), ], ), Stack( children: [ if (room.isRoomAdmin) Positioned( right: 4, top: 4, child: IconButton( onPressed: controller.setTopicAction, icon: const Icon(Icons.edit_outlined), ), ), Padding( padding: const EdgeInsets.only( left: 32.0, right: 32.0, top: 16.0, bottom: 16.0, ), child: SelectableLinkify( text: room.topic.isEmpty ? room.isSpace ? L10n.of(context).noSpaceDescriptionYet : L10n.of(context).noChatDescriptionYet : room.topic, options: const LinkifyOptions(humanize: false), linkStyle: const TextStyle( color: Colors.blueAccent, decorationColor: Colors.blueAccent, ), style: TextStyle( fontSize: 14, fontStyle: room.topic.isEmpty ? FontStyle.italic : FontStyle.normal, color: theme.textTheme.bodyMedium!.color, decorationColor: theme.textTheme.bodyMedium!.color, ), onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ), ), ], ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: RoomDetailsButtonRow( controller: controller, room: room, ), ), ], ) : Padding( padding: const EdgeInsets.all(16.0), child: RoomParticipantsSection(room: room), ), ), ), ); }, ); } } class RoomDetailsButtonRow extends StatefulWidget { final ChatDetailsController controller; final Room room; const RoomDetailsButtonRow({ super.key, required this.controller, required this.room, }); @override State createState() => RoomDetailsButtonRowState(); } class RoomDetailsButtonRowState extends State { StreamSubscription? notificationChangeSub; @override void initState() { super.initState(); notificationChangeSub ??= Matrix.of(context) .client .onSync .stream .where( (syncUpdate) => syncUpdate.accountData?.any( (accountData) => accountData.type == 'm.push_rules', ) ?? false, ) .listen( (u) => setState(() {}), ); } @override void dispose() { notificationChangeSub?.cancel(); super.dispose(); } final double _buttonWidth = 125.0; final double _buttonHeight = 84.0; final double _miniButtonWidth = 50.0; Room get room => widget.room; List _buttons(BuildContext context) { final L10n l10n = L10n.of(context); return [ ButtonDetails( title: l10n.activities, icon: const Icon(Icons.event_note_outlined, size: 30.0), onPressed: () => context.go("/rooms/${room.id}/details/planner"), visible: room.canChangeStateEvent(PangeaEventTypes.activityPlan) || room.isSpace, enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan), ), ButtonDetails( title: l10n.permissions, icon: const Icon(Icons.edit_attributes_outlined, size: 30.0), onPressed: () => context.go('/rooms/${room.id}/details/permissions'), visible: (room.isRoomAdmin && !room.isDirectChat) || room.isSpace, enabled: room.isRoomAdmin && !room.isDirectChat, ), ButtonDetails( title: l10n.access, icon: const Icon(Icons.shield_outlined, size: 30.0), onPressed: () => context.go('/rooms/${room.id}/details/access'), visible: room.isSpace && room.spaceParents.isEmpty, enabled: room.isSpace && room.isRoomAdmin, ), ButtonDetails( title: room.pushRuleState == PushRuleState.notify ? l10n.notificationsOn : l10n.notificationsOff, icon: Icon( room.pushRuleState == PushRuleState.notify ? Icons.notifications_on_outlined : Icons.notifications_off_outlined, size: 30.0, ), onPressed: () => showFutureLoadingDialog( context: context, future: () => room.setPushRuleState( room.pushRuleState == PushRuleState.notify ? PushRuleState.mentionsOnly : PushRuleState.notify, ), ), visible: !room.isSpace, ), ButtonDetails( title: l10n.invite, icon: const Icon(Icons.person_add_outlined, size: 30.0), onPressed: () => context.go('/rooms/${room.id}/details/invite'), visible: (room.canInvite && !room.isDirectChat) || room.isSpace, enabled: room.canInvite && !room.isDirectChat, ), ButtonDetails( title: l10n.addSubspace, icon: const Icon(Icons.add_outlined, size: 30.0), onPressed: widget.controller.addSubspace, visible: room.isSpace && room.canChangeStateEvent( EventTypes.SpaceChild, ), showInMainView: false, ), ButtonDetails( title: l10n.downloadSpaceAnalytics, icon: const Icon(Icons.download_outlined, size: 30.0), onPressed: () { showDialog( context: context, builder: (context) => DownloadAnalyticsDialog(space: room), ); }, visible: room.isSpace && room.isRoomAdmin, showInMainView: false, ), ButtonDetails( title: l10n.download, icon: const Icon(Icons.download_outlined, size: 30.0), onPressed: widget.controller.downloadChatAction, visible: room.ownPowerLevel >= 50 && !room.isSpace, ), ButtonDetails( title: l10n.botSettings, icon: const BotFace( width: 30.0, expression: BotExpression.idle, ), onPressed: () => showDialog( context: context, builder: (BuildContext context) => ConversationBotSettingsDialog( room: room, onSubmit: widget.controller.setBotOptions, ), ), visible: !room.isSpace && !room.isDirectChat && room.canInvite, ), ButtonDetails( title: l10n.chatCapacity, icon: const Icon(Icons.reduce_capacity, size: 30.0), onPressed: widget.controller.setRoomCapacity, visible: !room.isSpace && !room.isDirectChat && room.canSendDefaultStates, ), ButtonDetails( title: l10n.leave, icon: const Icon(Icons.logout_outlined, size: 30.0), onPressed: () async { final confirmed = await showOkCancelAlertDialog( useRootNavigator: false, context: context, title: L10n.of(context).areYouSure, okLabel: L10n.of(context).leave, cancelLabel: L10n.of(context).no, message: room.isSpace ? L10n.of(context).leaveSpaceDescription : L10n.of(context).leaveRoomDescription, isDestructive: true, ); if (confirmed != OkCancelResult.ok) return; final resp = await showFutureLoadingDialog( context: context, future: room.isSpace ? room.leaveSpace : room.leave, ); if (!resp.isError) { context.go("/rooms?spaceId=clear"); } }, visible: room.membership == Membership.join, showInMainView: false, ), ButtonDetails( title: l10n.delete, icon: const Icon(Icons.delete_outline, size: 30.0), onPressed: () async { if (room.isSpace) { final resp = await showDialog( context: context, builder: (_) => DeleteSpaceDialog(space: room), ); if (resp == true) { context.go("/rooms?spaceId=clear"); } } else { final confirmed = await showOkCancelAlertDialog( context: context, title: L10n.of(context).areYouSure, okLabel: L10n.of(context).delete, cancelLabel: L10n.of(context).cancel, isDestructive: true, message: room.isSpace ? L10n.of(context).deleteSpaceDesc : L10n.of(context).deleteChatDesc, ); if (confirmed != OkCancelResult.ok) return; final resp = await showFutureLoadingDialog( context: context, future: room.delete, ); if (resp.isError) return; context.go("/rooms?spaceId=clear"); } }, visible: room.isRoomAdmin, showInMainView: false, ), ]; } @override Widget build(BuildContext context) { final buttons = _buttons(context) .where( (button) => button.visible, ) .toList(); return Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: LayoutBuilder( builder: (context, constraints) { final availableWidth = constraints.maxWidth; final fullButtonCapacity = (availableWidth / _buttonWidth).floor() - 1; final miniButtonCapacity = (availableWidth / _miniButtonWidth).floor() - 1; final mini = fullButtonCapacity < 4; final capacity = mini ? miniButtonCapacity : fullButtonCapacity; List mainViewButtons = buttons.where((button) => button.showInMainView).toList(); final List otherButtons = buttons.where((button) => !button.showInMainView).toList(); if (capacity < mainViewButtons.length) { otherButtons.addAll(mainViewButtons.skip(capacity)); mainViewButtons = mainViewButtons.take(capacity).toList(); } return Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(mainViewButtons.length + 1, (index) { if (index == mainViewButtons.length) { if (otherButtons.isEmpty) { return const SizedBox(); } return PopupMenuButton( useRootNavigator: true, onSelected: (button) => button.onPressed?.call(), itemBuilder: (context) { return otherButtons .map( (button) => PopupMenuItem( value: button, child: Row( children: [ button.icon, const SizedBox(width: 8), Text(button.title), ], ), ), ) .toList(); }, child: RoomDetailsButton( mini: mini, buttonDetails: ButtonDetails( title: L10n.of(context).more, icon: const Icon(Icons.more_horiz_outlined), visible: true, ), width: mini ? _miniButtonWidth : _buttonWidth, height: mini ? _miniButtonWidth : _buttonHeight, ), ); } final button = buttons[index]; return RoomDetailsButton( mini: mini, buttonDetails: button, width: mini ? _miniButtonWidth : _buttonWidth, height: mini ? _miniButtonWidth : _buttonHeight, ); }), ); }, ), ); } } class RoomDetailsButton extends StatelessWidget { final bool mini; // final bool visible; // final bool enabled; // final String title; // final Widget icon; // final VoidCallback? onPressed; final double width; final double height; final ButtonDetails buttonDetails; const RoomDetailsButton({ super.key, required this.buttonDetails, // required this.visible, // required this.title, // required this.icon, required this.mini, required this.width, required this.height, // this.enabled = true, // this.onPressed, }); @override Widget build(BuildContext context) { if (!buttonDetails.visible) { return const SizedBox(); } return TooltipVisibility( visible: mini, child: Tooltip( message: buttonDetails.title, child: AbsorbPointer( absorbing: !buttonDetails.enabled, child: MouseRegion( cursor: SystemMouseCursors.click, child: HoverBuilder( builder: (context, hovered) { return GestureDetector( onTap: buttonDetails.onPressed, child: Opacity( opacity: buttonDetails.enabled ? 1.0 : 0.5, child: Container( width: width, height: height, decoration: BoxDecoration( color: hovered ? Theme.of(context) .colorScheme .primary .withAlpha(50) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.all(12.0), child: mini ? buttonDetails.icon : Column( spacing: 12.0, mainAxisAlignment: MainAxisAlignment.center, children: [ buttonDetails.icon, Text( buttonDetails.title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 12.0), ), ], ), ), ), ); }, ), ), ), ), ); } } class ButtonDetails { final String title; final Widget icon; final VoidCallback? onPressed; final bool visible; final bool enabled; final bool showInMainView; const ButtonDetails({ required this.title, required this.icon, required this.visible, this.onPressed, this.enabled = true, this.showInMainView = true, }); } class RoomParticipantsSection extends StatelessWidget { final Room room; const RoomParticipantsSection({ required this.room, super.key, }); final double _width = 100.0; final double _padding = 12.0; double get _fullWidth => _width + (_padding * 2); @override Widget build(BuildContext context) { final List members = room.getParticipants().toList() ..sort((b, a) => a.powerLevel.compareTo(b.powerLevel)); final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) + (room.summary.mJoinedMemberCount ?? 0); return LayoutBuilder( builder: (context, constraints) { final availableWidth = constraints.maxWidth; final capacity = (availableWidth / _fullWidth).floor(); return LoadParticipantsUtil( space: room, builder: (participantsLoader) { if (capacity < 4) { return Column( children: [ ...members.map((member) => ParticipantListItem(member)), if (actualMembersCount - members.length > 0) ListTile( title: Text( L10n.of(context).loadCountMoreParticipants( (actualMembersCount - members.length), ), ), leading: CircleAvatar( backgroundColor: Theme.of(context).scaffoldBackgroundColor, child: const Icon( Icons.group_outlined, color: Colors.grey, ), ), onTap: () => context.push( '/rooms/${room.id}/details/members', ), trailing: const Icon(Icons.chevron_right_outlined), ), ], ); } final filteredParticipants = participantsLoader.filteredParticipants(""); return Wrap( alignment: WrapAlignment.center, runAlignment: WrapAlignment.center, children: [ ...filteredParticipants.mapIndexed((index, user) { final publicProfile = participantsLoader.getPublicProfile( user.id, ); LinearGradient? gradient = index.leaderboardGradient; if (user.id == BotName.byEnvironment || publicProfile == null || publicProfile.level == null) { gradient = null; } return Padding( padding: EdgeInsets.all(_padding), child: SizedBox( width: _width, child: Column( children: [ Stack( alignment: Alignment.center, children: [ if (gradient != null) CircleAvatar( radius: _width / 2, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, gradient: gradient, ), ), ) else SizedBox( height: _width, width: _width, ), Builder( builder: (context) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => showMemberActionsPopupMenu( context: context, user: user, ), child: Center( child: Avatar( mxContent: user.avatarUrl, name: user.calcDisplayname(), size: _width - 6.0, presenceUserId: user.id, showPresence: false, ), ), ), ); }, ), ], ), Text( user.calcDisplayname(), style: Theme.of(context) .textTheme .labelLarge ?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, ), LevelDisplayName( userId: user.id, textStyle: Theme.of(context).textTheme.labelSmall, ), ], ), ), ); }), ], ); }, ); }, ); } }