refactor: room details page redesign

pull/2245/head
ggurdin 6 months ago committed by GitHub
parent bbd3d29f55
commit bc77056b96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -4956,5 +4956,9 @@
"changeTheDescription": "Change the description",
"changeThePermissions": "Change the permissions",
"introductions": "Introductions",
"announcements": "Announcements"
"announcements": "Announcements",
"activities": "Activities",
"access": "Access",
"addSubspace": "Add subspace",
"botSettings": "Bot settings"
}

@ -227,27 +227,19 @@ abstract class AppRoutes {
: null;
if (room != null && room.isSpace) {
// If a user is on mobile and they end up on the space
// page, redirect them and set the activeSpaceId
if (!isColumnMode &&
if (isColumnMode &&
(state.fullPath?.endsWith(':roomid') ?? false)) {
return '/rooms?spaceId=${room.id}';
return '/rooms/${room.id}/details?spaceId=${room.id}';
}
}
if (state.uri.queryParameters.containsKey('spaceId')) {
final spaceId = state.uri.queryParameters['spaceId'];
if (spaceId == null || spaceId == 'clear') {
// Have to load chat list to clear the spaceId, so don't redirect
return null;
}
// If spaceId is not null, and on web, and not on the space page,
// redirect to the space page
if (isColumnMode &&
!(state.fullPath?.endsWith(':roomid') ?? false)) {
return '/rooms/$spaceId?spaceId=$spaceId';
}
final spaceId = state.uri.queryParameters['spaceId'];
if (spaceId != null &&
spaceId != 'clear' &&
isColumnMode &&
state.fullPath != null &&
!state.fullPath!.contains('details')) {
return '/rooms/$spaceId/details?spaceId=$spaceId';
}
return null;
@ -595,30 +587,6 @@ abstract class AppRoutes {
redirect: loggedOutRedirect,
),
// #Pangea
GoRoute(
path: 'planner',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ActivityPlannerPage(
roomID: state.pathParameters['roomid']!,
),
),
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: '/generator',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ActivityGenerator(
roomID: state.pathParameters['roomid']!,
),
),
),
],
),
// GoRoute(
// path: 'encryption',
// pageBuilder: (context, state) => defaultPageBuilder(
@ -650,6 +618,32 @@ abstract class AppRoutes {
),
),
routes: [
// #Pangea
GoRoute(
path: 'planner',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ActivityPlannerPage(
roomID: state.pathParameters['roomid']!,
),
),
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: '/generator',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ActivityGenerator(
roomID: state.pathParameters['roomid']!,
),
),
),
],
),
// Pangea#
GoRoute(
path: 'access',
pageBuilder: (context, state) => defaultPageBuilder(

@ -50,7 +50,6 @@ import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/learning_settings/widgets/p_language_dialog.dart';
import 'package:fluffychat/pangea/spaces/pages/pangea_space_page.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/utils/error_reporter.dart';
@ -88,6 +87,16 @@ class ChatPage extends StatelessWidget {
Widget build(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(roomId);
// #Pangea
if (room?.isSpace ?? false) {
ErrorHandler.logError(
e: "Space chat opened",
s: StackTrace.current,
data: {"roomId": roomId},
);
context.go("/rooms");
}
if (room == null || room.membership == Membership.leave) {
// if (room == null) {
// Pangea#
@ -102,12 +111,6 @@ class ChatPage extends StatelessWidget {
);
}
// #Pangea
if (room.isSpace) {
return PangeaSpacePage(space: room);
}
// Pangea#
return ChatPageWithRoom(
key: Key('chat_page_${roomId}_$eventId'),
room: room,

@ -13,7 +13,6 @@ import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart';
import 'package:fluffychat/pages/chat/chat_app_bar_title.dart';
import 'package:fluffychat/pages/chat/chat_event_list.dart';
import 'package:fluffychat/pages/chat/pinned_events.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_page_launch_icon_button.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_input_bar.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_input_bar_header.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_view_background.dart';
@ -135,7 +134,6 @@ class ChatView extends StatelessWidget {
context.go('/rooms/${controller.room.id}/search');
},
),
ActivityPlanPageLaunchIconButton(controller: controller),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: L10n.of(context).chatDetails,

@ -1,3 +1,6 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -7,7 +10,13 @@ import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/settings/settings.dart';
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/chat_settings/pages/pangea_chat_details.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.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/set_class_name.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/utils/platform_infos.dart';
@ -264,9 +273,138 @@ class ChatDetailsController extends State<ChatDetails> {
if (mounted) setState(() {});
}
@override
void dispose() {
super.dispose();
void downloadChatAction() async {
if (roomId == null) return;
final Room? room = Matrix.of(context).client.getRoomById(roomId!);
if (room == null) return;
final type = await showModalActionPopup(
context: context,
title: L10n.of(context).downloadGroupText,
actions: [
AdaptiveModalAction(
value: DownloadType.csv,
label: L10n.of(context).downloadCSVFile,
),
AdaptiveModalAction(
value: DownloadType.txt,
label: L10n.of(context).downloadTxtFile,
),
AdaptiveModalAction(
value: DownloadType.xlsx,
label: L10n.of(context).downloadXLSXFile,
),
],
);
if (type == null) return;
downloadChat(room, type, context);
}
Future<void> setBotOptions(BotOptionsModel botOptions) async {
if (roomId == null) return;
final Room? room = Matrix.of(context).client.getRoomById(roomId!);
if (room == null) return;
try {
await Matrix.of(context).client.setRoomStateWithKey(
room.id,
PangeaEventTypes.botOptions,
'',
botOptions.toJson(),
);
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: err,
s: stack,
data: {
"botOptions": botOptions.toJson(),
"roomID": room.id,
},
);
}
}
Future<void> setRoomCapacity() async {
if (roomId == null) return;
final Room? room = Matrix.of(context).client.getRoomById(roomId!);
if (room == null) return;
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).chatCapacity,
message: L10n.of(context).chatCapacityExplanation,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
initialText: ((room.capacity != null) ? '${room.capacity}' : ''),
keyboardType: TextInputType.number,
maxLength: 3,
validator: (value) {
if (value.isEmpty ||
int.tryParse(value) == null ||
int.parse(value) < 0) {
return L10n.of(context).enterNumber;
}
if (int.parse(value) < (room.summary.mJoinedMemberCount ?? 1)) {
return L10n.of(context).chatCapacitySetTooLow;
}
return null;
},
);
if (input == null || input.isEmpty || int.tryParse(input) == null) {
return;
}
final newCapacity = int.parse(input);
final success = await showFutureLoadingDialog(
context: context,
future: () => room.updateRoomCapacity(newCapacity),
);
if (success.error == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).chatCapacityHasBeenChanged),
),
);
setState(() {});
}
}
Future<void> addSubspace() async {
final names = await showTextInputDialog(
context: context,
title: L10n.of(context).createNewSpace,
hintText: L10n.of(context).spaceName,
minLines: 1,
maxLines: 1,
maxLength: 64,
validator: (text) {
if (text.isEmpty) {
return L10n.of(context).pleaseChoose;
}
return null;
},
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 {
final activeSpace = client.getRoomById(roomId!)!;
await activeSpace.postLoad();
final resp = await client.createSpace(
name: names,
visibility: activeSpace.joinRules == JoinRules.public
? sdk.Visibility.public
: sdk.Visibility.private,
);
await activeSpace.pangeaSetSpaceChild(resp);
},
);
if (result.error != null) return;
}
// Pangea#
}

@ -1022,17 +1022,7 @@ class ChatListController extends State<ChatList>
context: context,
// #Pangea
// future: () => space.setSpaceChild(room.id),
future: () async {
try {
await space.pangeaSetSpaceChild(room.id);
} catch (err) {
if (err is NestedSpaceError) {
throw L10n.of(context).nestedSpaceError;
} else {
rethrow;
}
}
},
future: () => space.pangeaSetSpaceChild(room.id),
// Pangea#
);
// #Pangea

@ -20,7 +20,6 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart';
import 'package:fluffychat/pangea/spaces/widgets/space_view_leaderboard.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
@ -722,16 +721,6 @@ class _SpaceViewState extends State<SpaceView> {
final filter = _filterController.text.trim().toLowerCase();
return CustomScrollView(
slivers: [
// #Pangea
SliverList.builder(
itemCount: 1,
itemBuilder: (context, i) {
return SpaceViewLeaderboard(
space: room,
);
},
),
// Pangea#
SliverAppBar(
floating: true,
toolbarHeight: 72,

@ -1,40 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
class ActivityPlanPageLaunchIconButton extends StatelessWidget {
const ActivityPlanPageLaunchIconButton({
super.key,
required this.controller,
});
final ChatController controller;
@override
Widget build(BuildContext context) {
if (!controller.room.canSendDefaultStates) {
return const SizedBox();
}
return FutureBuilder<bool>(
future: controller.room.isBotDM,
builder: (BuildContext context, snapshot) {
final isBotDM = snapshot.data;
if (isBotDM == true || isBotDM == null) {
return const SizedBox();
}
return IconButton(
icon: const Icon(Icons.event_note_outlined),
tooltip: L10n.of(context).activityPlannerTitle,
onPressed: () {
context.go('/rooms/${controller.room.id}/planner');
},
);
},
);
}
}

@ -33,7 +33,7 @@ class ActivityPlannerPageAppBar extends StatelessWidget
children: [
const SizedBox(width: 8.0),
IconButton(
icon: const Icon(Icons.close),
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
],
@ -69,7 +69,7 @@ class ActivityPlannerPageAppBar extends StatelessWidget
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => roomID != null
? context.go('/rooms/$roomID/planner/generator')
? context.go('/rooms/$roomID/details/planner/generator')
: context.go("/rooms/homepage/planner/generator"),
child: Container(
decoration: BoxDecoration(

@ -1,97 +1,94 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/user/models/profile_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LevelDisplayName extends StatefulWidget {
class LevelDisplayName extends StatelessWidget {
final String userId;
final TextStyle? textStyle;
final double? iconSize;
const LevelDisplayName({
required this.userId,
this.textStyle,
this.iconSize,
super.key,
});
@override
State<LevelDisplayName> createState() => LevelDisplayNameState();
}
class LevelDisplayNameState extends State<LevelDisplayName> {
PublicProfileModel? _profile;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchProfile();
}
Future<void> _fetchProfile() async {
try {
final userController = MatrixState.pangeaController.userController;
_profile = await userController.getPublicProfile(widget.userId);
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
if (_profile != null && _profile!.isEmpty) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 0,
vertical: 2.0,
),
child: Row(
children: <Widget>[
if (_loading)
const CircularProgressIndicator()
else if (_error != null || _profile == null)
const SizedBox()
else
Row(
spacing: 4.0,
children: [
if (_profile?.baseLanguage != null &&
_profile?.targetLanguage != null)
Text(
_profile!.baseLanguage!.langCodeShort.toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
if (_profile?.baseLanguage != null &&
_profile?.targetLanguage != null)
const Icon(
Icons.arrow_forward_outlined,
size: 16.0,
),
if (_profile?.targetLanguage != null)
Text(
_profile!.targetLanguage!.langCodeShort.toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
if (_profile?.level != null) const Text(""),
if (_profile?.level != null)
Text(
"${_profile!.level!}",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
child: FutureBuilder(
future: MatrixState.pangeaController.userController
.getPublicProfile(userId),
builder: (context, snapshot) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (!snapshot.hasData)
const Padding(
padding: EdgeInsets.all(4.0),
child: SizedBox(
width: 12.0,
height: 12.0,
child: CircularProgressIndicator.adaptive(),
),
],
),
],
)
else if (snapshot.hasError || snapshot.data == null)
const SizedBox()
else
Row(
children: [
if (snapshot.data?.baseLanguage != null &&
snapshot.data?.targetLanguage != null)
Text(
snapshot.data!.baseLanguage!.langCodeShort
.toUpperCase(),
style: textStyle ??
TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
if (snapshot.data?.baseLanguage != null &&
snapshot.data?.targetLanguage != null)
Icon(
Icons.chevron_right_outlined,
size: iconSize ?? 16.0,
),
if (snapshot.data?.targetLanguage != null)
Text(
snapshot.data!.targetLanguage!.langCodeShort
.toUpperCase(),
style: textStyle ??
TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 4.0),
if (snapshot.data?.level != null)
Text(
"",
style: textStyle,
),
if (snapshot.data?.level != null)
Text(
"${snapshot.data!.level!}",
style: textStyle ??
TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
],
);
},
),
);
}

@ -1,30 +1,36 @@
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'dart:math';
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/app_config.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/utils/download_chat.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/class_name_header.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/chat_settings/widgets/download_space_analytics_button.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/visibility_toggle.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/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_modal_action_popup.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/user_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';
@ -33,34 +39,6 @@ class PangeaChatDetailsView extends StatelessWidget {
const PangeaChatDetailsView(this.controller, {super.key});
void _downloadChat(BuildContext context) async {
if (controller.roomId == null) return;
final Room? room =
Matrix.of(context).client.getRoomById(controller.roomId!);
if (room == null) return;
final type = await showModalActionPopup(
context: context,
title: L10n.of(context).downloadGroupText,
actions: [
AdaptiveModalAction(
value: DownloadType.csv,
label: L10n.of(context).downloadCSVFile,
),
AdaptiveModalAction(
value: DownloadType.txt,
label: L10n.of(context).downloadTxtFile,
),
AdaptiveModalAction(
value: DownloadType.xlsx,
label: L10n.of(context).downloadXLSXFile,
),
],
);
if (type == null) return;
downloadChat(room, type, context);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -77,8 +55,6 @@ class PangeaChatDetailsView extends StatelessWidget {
);
}
final bool isGroupChat = !room.isDirectChat && !room.isSpace;
return StreamBuilder(
stream: room.client.onRoomState.stream
.where((update) => update.roomId == room.id),
@ -89,14 +65,15 @@ class PangeaChatDetailsView extends StatelessWidget {
final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) +
(room.summary.mJoinedMemberCount ?? 0);
final canRequestMoreMembers = members.length < actualMembersCount;
final iconColor = theme.textTheme.bodyLarge!.color;
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
return Scaffold(
appBar: AppBar(
leading: controller.widget.embeddedCloseButton ??
const Center(child: BackButton()),
(room.isSpace
? const SizedBox()
: const Center(child: BackButton())),
elevation: theme.appBarTheme.elevation,
title: ClassNameHeader(
controller: controller,
@ -109,7 +86,7 @@ class PangeaChatDetailsView extends StatelessWidget {
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: members.length + 1 + (canRequestMoreMembers ? 1 : 0),
itemCount: 2,
itemBuilder: (BuildContext context, int i) => i == 0
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -265,256 +242,569 @@ class PangeaChatDetailsView extends StatelessWidget {
),
],
),
Divider(color: theme.dividerColor, height: 1),
if (isGroupChat && room.canInvite)
ConversationBotSettings(
key: controller.addConversationBotKey,
room: room,
),
if (isGroupChat && room.canInvite)
Divider(color: theme.dividerColor, height: 1),
if (room.canInvite && !room.isDirectChat)
ListTile(
title: Text(
L10n.of(context).inviteContact,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor:
Theme.of(context).textTheme.bodyLarge!.color,
child: const Icon(
Icons.person_add_outlined,
),
),
onTap: () =>
context.push('/rooms/${room.id}/invite'),
),
if (room.canInvite && !room.isDirectChat)
Divider(color: theme.dividerColor, height: 1),
if (room.isRoomAdmin &&
room.isSpace &&
room.spaceParents.isEmpty)
VisibilityToggle(
room: room,
setVisibility: controller.setVisibility,
setJoinRules: controller.setJoinRules,
iconColor: iconColor,
),
if (room.isRoomAdmin &&
room.isSpace &&
room.spaceParents.isEmpty)
Divider(color: theme.dividerColor, height: 1),
if (room.isRoomAdmin && !room.isDirectChat)
ListTile(
title: Text(
L10n.of(context).permissions,
style: TextStyle(
color: theme.colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
L10n.of(context).whoCanPerformWhichAction,
),
leading: CircleAvatar(
backgroundColor: theme.scaffoldBackgroundColor,
foregroundColor: iconColor,
child: const Icon(
Icons.edit_attributes_outlined,
),
),
onTap: () => context.push(
'/rooms/${room.id}/details/permissions',
),
),
if (room.isRoomAdmin && !room.isDirectChat)
Divider(color: theme.dividerColor, height: 1),
if (!room.isSpace && !room.isDirectChat)
RoomCapacityButton(
room: room,
controller: controller,
),
if (room.isSpace && room.isRoomAdmin && kIsWeb)
DownloadSpaceAnalyticsButton(space: room),
Divider(color: theme.dividerColor, height: 1),
if (room.ownPowerLevel >= 50 && !room.isSpace)
ListTile(
title: Text(
L10n.of(context).downloadGroupText,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: iconColor,
child: const Icon(
Icons.download_outlined,
),
),
onTap: () => _downloadChat(context),
),
if (room.ownPowerLevel >= 50 && !room.isSpace)
Divider(color: theme.dividerColor, height: 1),
if (isGroupChat)
ListTile(
title: Text(
room.pushRuleState == PushRuleState.notify
? L10n.of(context).notificationsOn
: L10n.of(context).notificationsOff,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: iconColor,
child: Icon(
room.pushRuleState == PushRuleState.notify
? Icons.notifications_on_outlined
: Icons.notifications_off_outlined,
),
),
onTap: controller.toggleMute,
),
if (isGroupChat)
Divider(color: theme.dividerColor, height: 1),
ListTile(
title: Text(
L10n.of(context).leave,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: iconColor,
child: const Icon(
Icons.logout_outlined,
),
),
onTap: () 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");
}
},
RoomDetailsButtonRow(
controller: controller,
room: room,
),
Divider(color: theme.dividerColor, height: 1),
if (room.isRoomAdmin)
ListTile(
title: Text(
L10n.of(context).delete,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold,
),
),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor: iconColor,
child: Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
),
onTap: () async {
if (room.isSpace) {
final resp = await showDialog<bool?>(
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");
}
},
),
Divider(color: theme.dividerColor, height: 1),
ListTile(
title: Text(
L10n.of(context).countParticipants(
actualMembersCount,
),
style: TextStyle(
color: theme.colorScheme.secondary,
fontWeight: FontWeight.bold,
],
)
: 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<RoomDetailsButtonRow> createState() => RoomDetailsButtonRowState();
}
class RoomDetailsButtonRowState extends State<RoomDetailsButtonRow> {
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 = 130.0;
final double _buttonHeight = 80.0;
final double _miniButtonWidth = 50.0;
final double _buttonPadding = 4.0;
double get _fullButtonWidth => _buttonWidth + (_buttonPadding * 2);
double get _fullMiniButtonWidth => _miniButtonWidth + (_buttonPadding * 2);
Room get room => widget.room;
List<ButtonDetails> _buttons(BuildContext context) {
final L10n l10n = L10n.of(context);
return [
ButtonDetails(
title: l10n.activities,
icon: const Icon(Icons.event_note_outlined),
onPressed: () => room.isSpace
? context.go("/rooms/homepage/planner")
: context.go("/rooms/${room.id}/details/planner"),
visible: (room) => room.canSendDefaultStates,
),
ButtonDetails(
title: l10n.permissions,
icon: const Icon(Icons.edit_attributes_outlined),
onPressed: () => context.go('/rooms/${room.id}/details/permissions'),
visible: (room) => room.isRoomAdmin && !room.isDirectChat,
),
ButtonDetails(
title: l10n.access,
icon: const Icon(Icons.shield_outlined),
onPressed: () => context.go('/rooms/${room.id}/details/access'),
visible: (room) => 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,
),
onPressed: () => showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentionsOnly
: PushRuleState.notify,
),
),
visible: (room) => !room.isSpace,
),
ButtonDetails(
title: l10n.invite,
icon: const Icon(Icons.person_add_outlined),
onPressed: () => context.go('/rooms/${room.id}/details/invite'),
visible: (room) => room.canInvite && !room.isDirectChat,
),
ButtonDetails(
title: l10n.addSubspace,
icon: const Icon(Icons.add_outlined),
onPressed: widget.controller.addSubspace,
visible: (room) =>
room.isSpace &&
room.canSendEvent(
EventTypes.SpaceChild,
),
),
ButtonDetails(
title: l10n.downloadSpaceAnalytics,
icon: const Icon(Icons.download_outlined),
onPressed: () {
showDialog(
context: context,
builder: (context) => DownloadAnalyticsDialog(space: room),
);
},
visible: (room) => room.isSpace && room.isRoomAdmin,
),
ButtonDetails(
title: l10n.download,
icon: const Icon(Icons.download_outlined),
onPressed: widget.controller.downloadChatAction,
visible: (room) => room.ownPowerLevel >= 50 && !room.isSpace,
),
ButtonDetails(
title: l10n.botSettings,
icon: const BotFace(
width: 30.0,
expression: BotExpression.idle,
),
onPressed: () => showDialog<BotOptionsModel?>(
context: context,
builder: (BuildContext context) => ConversationBotSettingsDialog(
room: room,
onSubmit: widget.controller.setBotOptions,
),
),
visible: (room) =>
!room.isSpace && !room.isDirectChat && room.canInvite,
),
ButtonDetails(
title: l10n.chatCapacity,
icon: const Icon(Icons.reduce_capacity),
onPressed: widget.controller.setRoomCapacity,
visible: (room) =>
!room.isSpace && !room.isDirectChat && room.canSendDefaultStates,
),
ButtonDetails(
title: l10n.leave,
icon: const Icon(Icons.logout_outlined),
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) => room.membership == Membership.join,
),
ButtonDetails(
title: l10n.delete,
icon: const Icon(Icons.delete_outline),
onPressed: () async {
if (room.isSpace) {
final resp = await showDialog<bool?>(
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) => room.isRoomAdmin,
),
];
}
@override
Widget build(BuildContext context) {
final buttons = _buttons(context)
.where(
(button) => button.visible(room),
)
.toList();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final fullButtonCapacity =
(availableWidth / _fullButtonWidth).floor() - 1;
final miniButtonCapacity =
(availableWidth / _fullMiniButtonWidth).floor() - 1;
final mini = fullButtonCapacity < 3;
final capacity = mini ? miniButtonCapacity : fullButtonCapacity;
final numVisibleButtons = min(buttons.length, capacity);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(numVisibleButtons + 1, (index) {
if (index == numVisibleButtons) {
if (buttons.length == numVisibleButtons) {
return const SizedBox();
} else if (buttons.length == numVisibleButtons + 1) {
return RoomDetailsButton(
mini: mini,
visible: true,
title: buttons[index].title,
icon: buttons[index].icon,
onPressed: buttons[index].onPressed,
width: mini ? _miniButtonWidth : _buttonWidth,
height: mini ? _miniButtonWidth : _buttonHeight,
);
}
return PopupMenuButton(
onSelected: (button) => button.onPressed(),
itemBuilder: (context) {
return buttons
.skip(numVisibleButtons)
.map(
(button) => PopupMenuItem(
value: button,
child: Row(
children: [
button.icon,
const SizedBox(width: 8),
Text(button.title),
],
),
),
)
.toList();
},
child: RoomDetailsButton(
mini: mini,
visible: true,
title: L10n.of(context).more,
icon: const Icon(Icons.more_horiz_outlined),
width: mini ? _miniButtonWidth : _buttonWidth,
height: mini ? _miniButtonWidth : _buttonHeight,
),
);
}
final button = buttons[index];
return Padding(
padding: EdgeInsets.symmetric(horizontal: _buttonPadding),
child: RoomDetailsButton(
mini: mini,
visible: button.visible(room),
title: button.title,
icon: button.icon,
onPressed: button.onPressed,
width: mini ? _miniButtonWidth : _buttonWidth,
height: mini ? _miniButtonWidth : _buttonHeight,
),
);
}),
);
},
),
);
}
}
class RoomDetailsButton extends StatelessWidget {
final bool mini;
final bool visible;
final String title;
final Widget icon;
final VoidCallback? onPressed;
final double width;
final double height;
const RoomDetailsButton({
super.key,
required this.visible,
required this.title,
required this.icon,
required this.mini,
required this.width,
required this.height,
this.onPressed,
});
@override
Widget build(BuildContext context) {
if (!visible) {
return const SizedBox();
}
return MouseRegion(
cursor: SystemMouseCursors.click,
child: HoverBuilder(
builder: (context, hovered) {
return GestureDetector(
onTap: onPressed,
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(8.0),
child: mini
? icon
: Column(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
Text(
title,
textAlign: TextAlign.center,
),
],
)
: i < members.length + 1
? ParticipantListItem(members[i - 1])
: ListTile(
title: Text(
L10n.of(context).loadCountMoreParticipants(
(actualMembersCount - members.length),
),
),
),
);
},
),
);
}
}
class ButtonDetails {
final String title;
final Widget icon;
final VoidCallback onPressed;
final bool Function(Room) visible;
const ButtonDetails({
required this.title,
required this.icon,
required this.onPressed,
required this.visible,
});
}
class RoomParticipantsSection extends StatelessWidget {
final Room room;
const RoomParticipantsSection({
required this.room,
super.key,
});
final double _width = 90.0;
final double _padding = 12.0;
double get _fullWidth => _width + (_padding * 2);
@override
Widget build(BuildContext context) {
final List<User> 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();
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),
),
],
);
}
return LoadParticipantsUtil(
space: room,
builder: (participantsLoader) {
final filteredParticipants =
participantsLoader.filteredParticipants("");
return Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
...filteredParticipants.mapIndexed((index, user) {
Color? color = index == 0
? AppConfig.gold
: index == 1
? Colors.grey[400]!
: index == 2
? Colors.brown[400]!
: null;
final publicProfile = participantsLoader.getPublicProfile(
user.id,
);
if (user.id == BotName.byEnvironment ||
publicProfile == null ||
publicProfile.level == null) {
color = null;
}
return Padding(
padding: EdgeInsets.all(_padding),
child: SizedBox(
width: _width,
child: Column(
children: [
Stack(
alignment: Alignment.center,
children: [
if (color != null)
CircleAvatar(
radius: _width / 2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: const Alignment(0.5, -0.5),
end: const Alignment(-0.5, 0.5),
colors: <Color>[
color,
Colors.white,
color,
],
),
),
),
)
else
SizedBox(
height: _width,
width: _width,
),
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => UserDialog.show(
context: context,
profile: Profile(
userId: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
),
),
child: Center(
child: Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
size: _width - 6.0,
presenceUserId: user.id,
showPresence: false,
),
),
),
),
],
),
leading: CircleAvatar(
backgroundColor: theme.scaffoldBackgroundColor,
child: const Icon(
Icons.group_outlined,
color: Colors.grey,
),
Text(
user.calcDisplayname(),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
onTap: () => context.push(
'/rooms/${controller.roomId!}/details/members',
LevelDisplayName(
userId: user.id,
textStyle: Theme.of(context).textTheme.labelSmall,
),
trailing: const Icon(Icons.chevron_right_outlined),
),
),
),
],
),
),
);
}),
],
);
},
);
},
);

@ -21,9 +21,6 @@ extension ChildrenAndParentsRoomExtension on Room {
}) async {
final Room? child = client.getRoomById(roomId);
if (child == null) return;
if (child.isSpace) {
throw NestedSpaceError();
}
for (final Room parent in pangeaSpaceParents) {
try {
@ -79,8 +76,3 @@ extension ChildrenAndParentsRoomExtension on Room {
)
.length;
}
class NestedSpaceError extends Error {
@override
String toString() => 'Cannot add a space to another space';
}

@ -1,71 +0,0 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/spaces/pages/pangea_space_page_view.dart';
import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart';
class PangeaSpacePage extends StatefulWidget {
final Room space;
const PangeaSpacePage({
required this.space,
super.key,
});
@override
State<PangeaSpacePage> createState() => PangeaSpacePageState();
}
class PangeaSpacePageState extends State<PangeaSpacePage> {
bool expanded = true;
final TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
@override
void initState() {
super.initState();
searchController.addListener(() {
if (mounted) {
setState(() {});
}
});
}
@override
void dispose() {
searchController.dispose();
searchFocusNode.dispose();
super.dispose();
}
void startSearch() {
setState(() {});
searchFocusNode.requestFocus();
}
void cancelSearch({bool unfocus = true}) {
setState(() {
searchController.clear();
});
if (unfocus) searchFocusNode.unfocus();
}
void toggleExpanded() {
setState(() {
expanded = !expanded;
});
}
@override
Widget build(BuildContext context) {
return LoadParticipantsUtil(
space: widget.space,
builder: (util) => PangeaSpacePageView(
this,
participantsLoader: util,
),
);
}
}

@ -1,622 +0,0 @@
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:material_symbols_icons/symbols.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_details/participant_list_item.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/spaces/pages/pangea_space_page.dart';
import 'package:fluffychat/pangea/spaces/utils/load_participants_util.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/user_dialog.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
class PangeaSpacePageView extends StatelessWidget {
final PangeaSpacePageState controller;
final LoadParticipantsUtilState participantsLoader;
const PangeaSpacePageView(
this.controller, {
required this.participantsLoader,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final room = controller.widget.space;
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
final filteredParticipants = participantsLoader
.filteredParticipants("")
.where((u) => u.id != BotName.byEnvironment)
.toList();
final bool showMedals = !participantsLoader.loading &&
controller.searchController.text.isEmpty &&
filteredParticipants.isNotEmpty;
final Widget leaderboardHeader = ListTile(
tileColor: Color.lerp(AppConfig.gold, Colors.black, 0.3),
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
visualDensity: const VisualDensity(vertical: -4.0),
title: Text(
L10n.of(context).leaderboard,
style: Theme.of(context).textTheme.headlineSmall,
),
trailing: Icon(
controller.expanded
? Icons.keyboard_arrow_down_outlined
: Icons.keyboard_arrow_right_outlined,
),
onTap: controller.toggleExpanded,
);
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
elevation: theme.appBarTheme.elevation,
backgroundColor: theme.appBarTheme.backgroundColor,
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.go(
'/rooms/${room.id}/details',
),
),
],
shape: Border(
bottom: BorderSide(
color: theme.dividerColor,
),
),
),
body: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: MaxWidthBody(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: [
Padding(
padding: const EdgeInsets.all(32.0),
child: Avatar(
mxContent: room.avatar,
name: displayname,
size: Avatar.defaultSize * 2.5,
),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
onPressed: () => FluffyShare.share(
displayname,
context,
copyOnly: true,
),
icon: const Icon(
Icons.copy_outlined,
size: 16,
),
style: TextButton.styleFrom(
foregroundColor:
theme.colorScheme.onSurface,
),
label: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 18),
),
),
Row(
spacing: 8.0,
children: [
TextButton.icon(
onPressed: () => context.push(
'/rooms/${room.id}/details/members',
),
icon: const Icon(
Icons.group_outlined,
size: 14,
),
style: TextButton.styleFrom(
foregroundColor:
theme.colorScheme.secondary,
),
label: Text(
L10n.of(context).countParticipants(
(room.summary.mInvitedMemberCount ??
0) +
(room.summary
.mJoinedMemberCount ??
0),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
TextButton.icon(
onPressed: () => context.push(
'/rooms/${room.id}/details/invite',
),
icon: const Icon(
Icons.group_add_outlined,
size: 14,
),
style: TextButton.styleFrom(
foregroundColor:
theme.colorScheme.secondary,
),
label: Text(
L10n.of(context).invite,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
Divider(color: theme.dividerColor, height: 1),
Padding(
padding: const EdgeInsets.only(
left: 24.0,
right: 24.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(),
),
),
if (constraints.maxWidth <= 800) leaderboardHeader,
if (constraints.maxWidth <= 800 && controller.expanded)
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 16.0,
children: [
SizedBox(
width: 200.0,
child: LeaderboardMedals(
isVisible: showMedals,
participants: filteredParticipants,
smallRadius: Avatar.defaultSize * 0.7,
largeRadius: Avatar.defaultSize,
),
),
if (filteredParticipants.isNotEmpty)
Expanded(
child: Column(
children: filteredParticipants
.take(3)
.mapIndexed((i, user) {
return TrophyParticipantListItem(
index: i,
user: user,
);
}).toList(),
),
),
],
),
),
],
),
),
),
if (constraints.maxWidth > 800)
Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: theme.dividerColor,
width: 1.0,
),
),
),
width: 350.0,
child: Column(
spacing: 16.0,
children: [
leaderboardHeader,
if (controller.expanded)
Expanded(
child: Column(
children: [
LeaderboardMedals(
isVisible: showMedals,
participants: filteredParticipants,
padding: EdgeInsets.only(
top: showMedals ? 16.0 : 0,
left: showMedals ? 42.0 : 0,
right: showMedals ? 42.0 : 0,
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
),
child: participantsLoader.loading
? const CircularProgressIndicator
.adaptive()
: Text(
L10n.of(context)
.countParticipants(
participantsLoader
.participants.length,
),
),
),
IconButton(
icon: const Icon(
Icons.group_add_outlined,
),
iconSize: 20.0,
onPressed: () => context.push(
'/rooms/${room.id}/details/members',
),
),
],
),
TextField(
controller: controller.searchController,
focusNode: controller.searchFocusNode,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
filled: true,
fillColor: theme
.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius:
BorderRadius.circular(99),
),
contentPadding: EdgeInsets.zero,
hintText: L10n.of(context).search,
hintStyle: TextStyle(
color: theme
.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
prefixIcon: controller.searchController
.text.isNotEmpty
? IconButton(
tooltip:
L10n.of(context).cancel,
icon: const Icon(
Icons.close_outlined,
),
onPressed:
controller.cancelSearch,
color: theme.colorScheme
.onPrimaryContainer,
)
: IconButton(
onPressed:
controller.startSearch,
icon: Icon(
Icons.search_outlined,
color: theme.colorScheme
.onPrimaryContainer,
),
),
),
),
],
),
),
Expanded(
child: Builder(
builder: (context) {
if (participantsLoader.loading) {
return const Column(
children: [
CircularProgressIndicator.adaptive(),
],
);
}
if (participantsLoader.error != null) {
return Text(
L10n.of(context).oopsSomethingWentWrong,
style: TextStyle(
color: theme.colorScheme.error,
),
);
}
return ListView.builder(
itemCount: filteredParticipants.length,
itemBuilder: (context, index) {
return TrophyParticipantListItem(
index: index,
user: filteredParticipants[index],
);
},
);
},
),
),
],
),
),
],
),
),
],
),
);
},
);
}
}
class LeaderboardMedal extends StatelessWidget {
final User user;
final Color color;
final double radius;
final double iconSize;
final double iconRadius;
final double? top;
final double? left;
final double? right;
final double? bottom;
const LeaderboardMedal(
this.user, {
required this.color,
required this.radius,
required this.iconSize,
required this.iconRadius,
this.top,
this.left,
this.right,
this.bottom,
super.key,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
top: top,
left: left,
right: right,
bottom: bottom != null ? bottom! + 10.0 : null,
child: CircleAvatar(
radius: radius + 3.0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: const Alignment(0.5, -0.5),
end: const Alignment(-0.5, 0.5),
colors: <Color>[
color,
Colors.white,
color,
],
),
),
),
),
),
Positioned(
top: top != null ? 3.0 : null,
left: left != null ? 3.0 : null,
right: right != null ? 3.0 : null,
bottom: bottom != null ? bottom! + 10.0 + 3.0 : null,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => UserDialog.show(
context: context,
profile: Profile(
userId: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
),
),
child: Center(
child: Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
size: radius * 2,
presenceUserId: user.id,
showPresence: false,
),
),
),
),
),
Positioned(
top: top != null ? ((radius + 3.0) * 2) - iconRadius : null,
left: left != null ? radius + 3.0 - iconRadius : null,
right: right != null ? radius + 3.0 - iconRadius : null,
bottom: bottom,
child: CircleAvatar(
backgroundColor: color,
radius: iconRadius,
child: Icon(
Symbols.trophy,
color: Colors.white,
size: iconSize,
),
),
),
],
);
}
}
class LeaderboardMedals extends StatelessWidget {
final bool isVisible;
final List<User> participants;
final EdgeInsets? padding;
final double? largeRadius;
final double? smallRadius;
const LeaderboardMedals({
super.key,
required this.isVisible,
required this.participants,
this.largeRadius,
this.smallRadius,
this.padding,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: isVisible ? Avatar.defaultSize * 3.5 : 0.0,
// padding: EdgeInsets.only(
// top: isVisible ? 16.0 : 0,
// left: isVisible ? 42.0 : 0,
// right: isVisible ? 42.0 : 0,
// ),
padding: padding,
child: !isVisible
? const SizedBox.shrink()
: Stack(
children: [
if (participants.length > 1)
LeaderboardMedal(
participants[1],
color: Colors.grey[400]!,
radius: smallRadius ?? Avatar.defaultSize * 0.75,
iconSize: 16.0,
iconRadius: 10.0,
bottom: 0.0,
left: 0.0,
),
if (participants.isNotEmpty)
LeaderboardMedal(
participants[0],
color: AppConfig.gold,
radius: largeRadius ?? Avatar.defaultSize * 1.25,
iconSize: 20.0,
iconRadius: 16.0,
top: 0.0,
right: 0.0,
left: 0.0,
),
if (participants.length > 2)
LeaderboardMedal(
participants[2],
color: Colors.brown[400]!,
radius: smallRadius ?? Avatar.defaultSize * 0.75,
bottom: 0.0,
right: 0.0,
iconSize: 16.0,
iconRadius: 10.0,
),
],
),
);
}
}
class TrophyParticipantListItem extends StatelessWidget {
final int index;
final User user;
const TrophyParticipantListItem({
required this.index,
required this.user,
super.key,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => UserDialog.show(
context: context,
profile: Profile(
userId: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
),
),
child: Row(
children: [
Container(
alignment: Alignment.centerRight,
width: 32.0,
child: (index < 3)
? Icon(
Symbols.trophy,
color: index == 0
? AppConfig.gold
: index == 1
? Colors.grey[400]
: index == 2
? Colors.brown[400]
: null,
)
: null,
),
Expanded(
child: AbsorbPointer(
child: ParticipantListItem(user),
),
),
],
),
);
}
}

@ -108,6 +108,10 @@ class LoadParticipantsUtilState extends State<LoadParticipantsUtil> {
}
}
PublicProfileModel? getPublicProfile(String userId) {
return _levelsCache[userId];
}
@override
Widget build(BuildContext context) {
return widget.builder(this);

@ -1,96 +0,0 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/spaces/pages/pangea_space_page_view.dart';
import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart';
import 'package:fluffychat/widgets/avatar.dart';
class SpaceViewLeaderboard extends StatefulWidget {
final Room space;
const SpaceViewLeaderboard({
required this.space,
super.key,
});
@override
State<SpaceViewLeaderboard> createState() => SpaceViewLeaderboardState();
}
class SpaceViewLeaderboardState extends State<SpaceViewLeaderboard> {
bool _expanded = true;
void _toggleExpanded() {
setState(() => _expanded = !_expanded);
}
@override
Widget build(BuildContext context) {
if (FluffyThemes.isColumnMode(context)) {
return const SizedBox.shrink();
}
return LoadParticipantsUtil(
space: widget.space,
builder: (participantsLoader) {
final filteredParticipants = participantsLoader
.filteredParticipants("")
.where((u) => u.id != BotName.byEnvironment)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16.0,
children: [
ListTile(
tileColor: Color.lerp(AppConfig.gold, Colors.black, 0.3),
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
visualDensity: const VisualDensity(vertical: -4.0),
title: Text(
L10n.of(context).leaderboard,
style: Theme.of(context).textTheme.headlineSmall,
),
trailing: Icon(
_expanded
? Icons.keyboard_arrow_down_outlined
: Icons.keyboard_arrow_right_outlined,
),
onTap: _toggleExpanded,
),
if (_expanded)
Column(
children: [
SizedBox(
width: 225.0,
child: LeaderboardMedals(
isVisible: !participantsLoader.loading &&
filteredParticipants.isNotEmpty,
participants: filteredParticipants,
smallRadius: Avatar.defaultSize * 0.7,
largeRadius: Avatar.defaultSize,
),
),
Column(
children: filteredParticipants.take(3).mapIndexed(
(index, user) {
return TrophyParticipantListItem(
index: index,
user: filteredParticipants[index],
);
},
).toList(),
),
],
),
],
);
},
);
}
}
Loading…
Cancel
Save