From 48a50744de8882612c6ba791d87b31def399d640 Mon Sep 17 00:00:00 2001 From: Welton Moura Date: Tue, 22 Sep 2026 14:42:26 -0300 Subject: [PATCH] feat: mural feed com criador, descricao e contador de inscritos --- lib/config/app_config.dart | 1 + lib/l10n/intl_en.arb | 10 ++ lib/l10n/intl_pt_BR.arb | 10 ++ lib/pages/chat_list/mural_list_item.dart | 150 +++++++++++++++++++++++ lib/pages/chat_list/tabs/murals_tab.dart | 42 ++++++- 5 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 lib/pages/chat_list/mural_list_item.dart diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index e951f6d..4de2492 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -21,6 +21,7 @@ abstract class AppConfig { static const String pushNotificationsAppId = 'chat.fluffy.tatuchat'; static const double borderRadius = 18.0; static const double spaceBorderRadius = 11.0; + static const double muralBorderRadius = 11.0; static const double columnWidth = 360.0; static const String enablePushTutorial = diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1fc2a01..f8324c6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -2469,6 +2469,16 @@ } } }, + "countFollowers": "{count, plural, =1{1 subscriber} other{{count} subscribers}}", + "@countFollowers": { + "type": "String", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "by": "by", "spaceMemberOf": "Space member of {spaces}", "@spaceMemberOf": { "type": "String", diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index 885c216..5139f73 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -2405,6 +2405,16 @@ } } }, + "countFollowers": "{count, plural, =0{0 inscritos} =1{1 inscrito} other{{count} inscritos}}", + "@countFollowers": { + "type": "String", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "by": "por", "spaceMemberOf": "Membro do espaço de {spaces}", "@spaceMemberOf": { "type": "String", diff --git a/lib/pages/chat_list/mural_list_item.dart b/lib/pages/chat_list/mural_list_item.dart new file mode 100644 index 0000000..6b04d62 --- /dev/null +++ b/lib/pages/chat_list/mural_list_item.dart @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:tatchat/config/app_config.dart'; +import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:tatchat/widgets/avatar.dart'; + +/// A row that represents a mural in the murals list. +/// +/// The first line shows the circular avatar, the mural name, the creator and +/// the number of subscribers. The second line is reserved for the description. +/// All colors are taken from the app theme, mirroring the regular chat list +/// rows. +class MuralListItem extends StatelessWidget { + final Room room; + final bool activeChat; + final void Function() onTap; + final void Function(BuildContext context)? onLongPress; + final Uri? displayAvatar; + final String? displayAvatarName; + final String? description; + final String? creatorName; + + const MuralListItem( + this.room, { + this.activeChat = false, + required this.onTap, + this.onLongPress, + this.displayAvatar, + this.displayAvatarName, + this.description, + this.creatorName, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = L10n.of(context); + final displayname = room.getLocalizedDisplayname(MatrixLocals(l10n)); + final isMuted = room.pushRuleState != PushRuleState.notify; + final backgroundColor = activeChat + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.surfaceContainerLow; + final description = this.description; + final creatorName = this.creatorName; + final followers = room.summary.mJoinedMemberCount ?? 0; + final mutedStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Material( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + color: backgroundColor, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress == null + ? null + : () => onLongPress?.call(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Avatar( + mxContent: displayAvatar ?? room.avatar, + name: displayAvatarName ?? displayname, + size: Avatar.defaultSize, + shapeBorder: const CircleBorder(), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (isMuted) + const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon(Icons.notifications_off_outlined, size: 16), + ), + if (room.isFavourite) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.push_pin, + size: 16, + color: theme.colorScheme.primary, + ), + ), + if (creatorName != null && creatorName.isNotEmpty) ...[ + const SizedBox(width: 12), + Flexible( + flex: 1, + child: Text( + '${l10n.by} $creatorName', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mutedStyle?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ), + ], + const SizedBox(width: 8), + Icon( + Icons.group_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text(l10n.countFollowers(followers), style: mutedStyle), + ], + ), + if (description != null && description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/chat_list/tabs/murals_tab.dart b/lib/pages/chat_list/tabs/murals_tab.dart index 68cb86e..316879e 100644 --- a/lib/pages/chat_list/tabs/murals_tab.dart +++ b/lib/pages/chat_list/tabs/murals_tab.dart @@ -3,11 +3,13 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +import 'dart:async'; + import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart'; -import 'package:tatchat/pages/chat_list/chat_list_item.dart'; +import 'package:tatchat/pages/chat_list/mural_list_item.dart'; import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart'; import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart'; import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; @@ -28,6 +30,7 @@ class MuralsTab extends StatefulWidget { class _MuralsTabState extends State { final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); + final Set _loadingRooms = {}; bool _isSearchMode = false; @override @@ -37,6 +40,25 @@ class _MuralsTabState extends State { super.dispose(); } + /// Loads the missing state events (e.g. `m.room.topic` and `m.room.create`) + /// of a mural room so that its description and creator are already visible in + /// the list without having to open the mural first. + Future _loadMissingRoomState(Room room) async { + final roomId = room.id; + try { + final stateEvents = await room.client.getRoomState(roomId); + for (final state in stateEvents) { + room.setState(StrippedStateEvent.fromJson(state.toJson())); + } + if (!mounted) return; + setState(() {}); + } catch (_) { + // State loading is best-effort, ignore failures (e.g. offline). + } finally { + _loadingRooms.remove(roomId); + } + } + @override Widget build(BuildContext context) { final client = Matrix.of(context).client; @@ -91,15 +113,23 @@ class _MuralsTabState extends State { itemCount: murals.length, itemBuilder: (context, i) { final room = murals[i]; + final roomId = room.id; + if ((room.topic.isEmpty || room.creatorUserIds.isEmpty) && + !_loadingRooms.contains(roomId)) { + _loadingRooms.add(roomId); + unawaited(_loadMissingRoomState(room)); + } final creatorId = room.creatorUserIds.isEmpty ? null : room.creatorUserIds.first; final creatorState = creatorId == null ? null : room.getState(EventTypes.RoomMember, creatorId); - return ChatListItem( + final creatorDisplayName = creatorState?.content + .tryGet('displayname'); + return MuralListItem( room, - key: Key('mural_list_item_${room.id}'), + key: Key('mural_list_item_$roomId'), onTap: () => widget.controller.onChatTap(room), onLongPress: (context) => widget.controller.chatContextAction(room, context), @@ -108,9 +138,9 @@ class _MuralsTabState extends State { creatorState?.content.tryGet('avatar_url') ?? '', ), - displayAvatarName: creatorState?.content.tryGet( - 'displayname', - ), + displayAvatarName: creatorDisplayName, + creatorName: creatorDisplayName ?? creatorId, + description: room.topic, ); }, ),