feat: course planner

This commit is contained in:
ggurdin 2025-08-25 15:43:17 -04:00 committed by GitHub
parent b5fe810aa1
commit f9ee134cdd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 12301 additions and 3201 deletions

File diff suppressed because it is too large Load diff

View file

@ -3387,7 +3387,7 @@
"chatTopic": "Chat topic",
"chatTopicDesc": "Set a chat topic",
"inviteStudentByUserNameDesc": "If your student already has an account, you can search for them.",
"classRoster": "Participants",
"participants": "Participants",
"almostPerfect": "That seems right! Here's what I would have said.",
"prettyGood": "Pretty good! Here's what I would have said.",
"letMeThink": "Hmm, let's see how you did!",
@ -5192,5 +5192,54 @@
}
},
"activityFinishedMessage": "All Finished!",
"endForAll": "End for all"
"endForAll": "End for all",
"newCourse": "New Course",
"newCourseSubtitle": "Which course template would you like to use?",
"failedToLoadCourses": "Failed to load courses",
"numModules": "{num} modules",
"@numModules": {
"type": "int",
"placeholders": {
"num": {
"type": "int"
}
}
},
"numActivityPlans": "{num} activity plans",
"@numActivityPlans": {
"type": "int",
"placeholders": {
"num": {
"type": "int"
}
}
},
"coursePlan": "Course Plan",
"editCourseLater": "You can edit template title, descriptions, and course image later.",
"newCourseAccess": "By default, courses are private and require admin approval to join. You can edit these settings at any time.",
"createCourse": "Create course",
"stats": "Stats",
"createGroupChat": "Create group chat",
"editCourse": "Edit course",
"inviteDesc": "By username, by code or link",
"editCourseDesc": "Here you can edit course title, description, etc.",
"permissionsDesc": "Set permissions such as who can invite users, send messages, create chats, etc.",
"accessDesc": "You can make your course open to the world! Or, make your course private and secure.",
"createGroupChatDesc": "Whereas activity sessions start and end, group chats will stay open for routine communication.",
"deleteDesc": "Only space admin can delete a chat. This is a destructive action which removes all users and deletes all chats. Proceed with caution.",
"failedToLoadCourseInfo": "Failed to load course information",
"noCourseFound": "No course information found",
"additionalParticipants": "+ {num} others",
"@additionalParticipants": {
"type": "int",
"placeholders": {
"num": {
"type": "int"
}
}
},
"activityNotFoundForCourse": "This activity was not found within the course",
"courseChats": "Course Chats",
"noSessionsFound": "None found. Ready to start?",
"myActivitySessions": "My Activity Sessions"
}

View file

@ -6,16 +6,19 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/settings/settings.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.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/pages/pangea_room_details.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/download/download_room_extension.dart';
import 'package:fluffychat/pangea/download/download_type_enum.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/join_rule_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
@ -221,7 +224,7 @@ class ChatDetailsController extends State<ChatDetails> {
@override
// #Pangea
Widget build(BuildContext context) => PangeaChatDetailsView(this);
Widget build(BuildContext context) => PangeaRoomDetailsView(this);
// Widget build(BuildContext context) => ChatDetailsView(this);
// Pangea#
@ -343,10 +346,69 @@ class ChatDetailsController extends State<ChatDetails> {
}
}
Future<void> addSubspace() async {
Future<void> addGroupChat() async {
final activeSpace = Matrix.of(context).client.getRoomById(roomId!);
if (activeSpace == null || !activeSpace.isSpace) return;
await activeSpace.addSubspace(context);
final names = await showTextInputDialog(
context: context,
title: L10n.of(context).createGroup,
hintText: L10n.of(context).groupName,
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 resp = await showFutureLoadingDialog<String>(
context: context,
future: () async {
final newRoomId = await Matrix.of(context).client.createGroupChat(
visibility: sdk.Visibility.private,
groupName: names,
initialState: [
RoomDefaults.defaultPowerLevels(
Matrix.of(context).client.userID!,
),
await Matrix.of(context).client.pangeaJoinRules(
'knock_restricted',
allow: roomId != null
? [
{
"type": "m.room_membership",
"room_id": roomId,
}
]
: null,
),
],
enableEncryption: false,
);
final client = Matrix.of(context).client;
Room? room = client.getRoomById(newRoomId);
if (room == null) {
await client.waitForRoomInSync(newRoomId);
room = client.getRoomById(newRoomId);
}
if (room == null) newRoomId;
await activeSpace.addToSpace(room!.id);
if (room.spaceParents.isEmpty) {
await client.waitForRoomInSync(newRoomId);
}
return newRoomId;
},
);
if (resp.isError || resp.result == null || !mounted) return;
context.go('/rooms/${resp.result}/invite');
}
// Pangea#
}

View file

@ -20,7 +20,13 @@ class ChatEncryptionSettings extends StatefulWidget {
}
class ChatEncryptionSettingsController extends State<ChatEncryptionSettings> {
String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
// #Pangea
// String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
String? get roomId {
final pathParameters = GoRouterState.of(context).pathParameters;
return pathParameters['roomid'] ?? pathParameters['spaceid'];
}
// Pangea#
Room get room => Matrix.of(context).client.getRoomById(roomId!)!;

View file

@ -13,7 +13,6 @@ import 'package:matrix/matrix.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
import 'package:fluffychat/pangea/chat_list/utils/app_version_util.dart';
@ -113,33 +112,25 @@ class ChatListController extends State<ChatList>
? ActiveFilter.messages
: ActiveFilter.allChats;
String? _activeSpaceId;
String? get activeSpaceId => _activeSpaceId;
void setActiveSpace(String spaceId) async {
await Matrix.of(context).client.getRoomById(spaceId)!.postLoad();
// #Pangea
if (FluffyThemes.isColumnMode(context)) {
context.push("/rooms/$spaceId/details");
}
// Pangea#
setState(() {
_activeSpaceId = spaceId;
});
}
// #Pangea
String? get activeSpaceId => widget.activeSpaceId;
// String? _activeSpaceId;
// String? get activeSpaceId => _activeSpaceId;
// void setActiveSpace(String spaceId) async {
// await Matrix.of(context).client.getRoomById(spaceId)!.postLoad();
// setState(() {
// _activeSpaceId = spaceId;
// });
// }
// void clearActiveSpace() => setState(() {
// _activeSpaceId = null;
// });
void clearActiveSpace() {
setState(() {
_activeSpaceId = null;
});
context.go("/rooms");
}
void clearActiveSpace() => context.go("/rooms");
void setActiveSpace(String spaceId) =>
context.go("/rooms/spaces/$spaceId/details");
// Pangea#
void onChatTap(Room room) async {
@ -524,9 +515,7 @@ class ChatListController extends State<ChatList>
//#Pangea
StreamSubscription? _invitedSpaceSubscription;
StreamSubscription? _subscriptionStatusStream;
StreamSubscription? _spaceChildSubscription;
StreamSubscription? _roomCapacitySubscription;
final Set<String> hasUpdates = {};
//Pangea#
@override
@ -625,11 +614,6 @@ class ChatListController extends State<ChatList>
// so that when the user navigates to the space that was updated, it will
// reload any rooms that have been added / removed
final client = MatrixState.pangeaController.matrixState.client;
_spaceChildSubscription ??= client.onRoomState.stream.where((u) {
return u.state.type == EventTypes.SpaceChild && u.roomId != activeSpaceId;
}).listen((update) {
hasUpdates.add(update.roomId);
});
// listen for room join events and leave room if over capacity
_roomCapacitySubscription ??= client.onSync.stream
@ -669,9 +653,6 @@ class ChatListController extends State<ChatList>
}
});
_activeSpaceId =
widget.activeSpaceId == 'clear' ? null : widget.activeSpaceId;
WidgetsBinding.instance.addPostFrameCallback((_) {
_joinInvitedSpaces();
});
@ -681,17 +662,6 @@ class ChatListController extends State<ChatList>
}
// #Pangea
@override
void didUpdateWidget(ChatList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.activeSpaceId != oldWidget.activeSpaceId &&
widget.activeSpaceId != null) {
widget.activeSpaceId == 'clear'
? clearActiveSpace()
: setActiveSpace(widget.activeSpaceId!);
}
}
Future<void> _joinInvitedSpaces() async {
final invitedSpaces = Matrix.of(context).client.rooms.where(
(r) => r.isSpace && r.membership == Membership.invite,
@ -711,7 +681,6 @@ class ChatListController extends State<ChatList>
//#Pangea
_invitedSpaceSubscription?.cancel();
_subscriptionStatusStream?.cancel();
_spaceChildSubscription?.cancel();
_roomCapacitySubscription?.cancel();
//Pangea#
scrollController.removeListener(_onScroll);
@ -1097,7 +1066,7 @@ class ChatListController extends State<ChatList>
builder: (_) => DeleteSpaceDialog(space: room),
);
if (resp == true && mounted) {
context.go("/rooms?spaceId=clear");
context.go("/rooms");
}
} else {
final confirmed = await showOkCancelAlertDialog(
@ -1118,7 +1087,9 @@ class ChatListController extends State<ChatList>
future: room.delete,
);
if (mounted && !resp.isError) {
context.go("/rooms");
activeSpaceId != null
? context.go('/rooms/spaces/$activeSpaceId/details')
: context.go("/rooms");
}
}
return;
@ -1257,29 +1228,31 @@ class ChatListController extends State<ChatList>
});
}
void setActiveClient(Client client) {
context.go('/rooms');
setState(() {
activeFilter = ActiveFilter.allChats;
_activeSpaceId = null;
Matrix.of(context).setActiveClient(client);
});
_clientStream.add(client);
}
// #Pangea
// void setActiveClient(Client client) {
// context.go('/rooms');
// setState(() {
// activeFilter = ActiveFilter.allChats;
// _activeSpaceId = null;
// Matrix.of(context).setActiveClient(client);
// });
// _clientStream.add(client);
// }
void setActiveBundle(String bundle) {
context.go('/rooms');
setState(() {
_activeSpaceId = null;
Matrix.of(context).activeBundle = bundle;
if (!Matrix.of(context)
.currentBundle!
.any((client) => client == Matrix.of(context).client)) {
Matrix.of(context)
.setActiveClient(Matrix.of(context).currentBundle!.first);
}
});
}
// void setActiveBundle(String bundle) {
// context.go('/rooms');
// setState(() {
// _activeSpaceId = null;
// Matrix.of(context).activeBundle = bundle;
// if (!Matrix.of(context)
// .currentBundle!
// .any((client) => client == Matrix.of(context).client)) {
// Matrix.of(context)
// .setActiveClient(Matrix.of(context).currentBundle!.first);
// }
// });
// }
// Pangea#
void editBundlesForAccount(String? userId, String? activeBundle) async {
final l10n = L10n.of(context);

View file

@ -9,8 +9,8 @@ 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/dummy_chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/space_view.dart';
import 'package:fluffychat/pangea/chat_list/widgets/pangea_chat_list_header.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_page.dart';
import 'package:fluffychat/pangea/onboarding/onboarding.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/utils/stream_extension.dart';
@ -31,19 +31,24 @@ class ChatListViewBody extends StatelessWidget {
final client = Matrix.of(context).client;
final activeSpace = controller.activeSpaceId;
if (activeSpace != null) {
return SpaceView(
// #Pangea
// return SpaceView(
// key: ValueKey(activeSpace),
// spaceId: activeSpace,
// onBack: controller.clearActiveSpace,
// onChatTab: (room) => controller.onChatTap(room),
// onChatContext: (room, context) =>
// controller.chatContextAction(room, context),
// activeChat: controller.activeChat,
// toParentSpace: controller.setActiveSpace,
// );
return CourseChats(
activeSpace,
key: ValueKey(activeSpace),
spaceId: activeSpace,
onBack: controller.clearActiveSpace,
onChatTab: (room) => controller.onChatTap(room),
onChatContext: (room, context) =>
controller.chatContextAction(room, context),
activeChat: controller.activeChat,
toParentSpace: controller.setActiveSpace,
// #Pangea
controller: controller,
// Pangea#
client: client,
);
// Pangea#
}
final spaces = client.rooms.where((r) => r.isSpace);
final spaceDelegateCandidates = <String, Room>{};

View file

@ -1,140 +1,142 @@
import 'package:flutter/material.dart';
// #Pangea
// import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
// import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/client_chooser_button.dart';
import 'package:fluffychat/utils/sync_status_localization.dart';
import '../../widgets/matrix.dart';
// import 'package:fluffychat/config/themes.dart';
// import 'package:fluffychat/l10n/l10n.dart';
// import 'package:fluffychat/pages/chat_list/chat_list.dart';
// import 'package:fluffychat/pages/chat_list/client_chooser_button.dart';
// import 'package:fluffychat/utils/sync_status_localization.dart';
// import '../../widgets/matrix.dart';
class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
final ChatListController controller;
final bool globalSearch;
// class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
// final ChatListController controller;
// final bool globalSearch;
const ChatListHeader({
super.key,
required this.controller,
this.globalSearch = true,
});
// const ChatListHeader({
// super.key,
// required this.controller,
// this.globalSearch = true,
// });
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final client = Matrix.of(context).client;
// @override
// Widget build(BuildContext context) {
// final theme = Theme.of(context);
// final client = Matrix.of(context).client;
return SliverAppBar(
floating: true,
toolbarHeight: 72,
pinned: FluffyThemes.isColumnMode(context),
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
automaticallyImplyLeading: false,
title: StreamBuilder(
stream: client.onSyncStatus.stream,
builder: (context, snapshot) {
final status = client.onSyncStatus.value ??
const SyncStatusUpdate(SyncStatus.waitingForResponse);
final hide = client.onSync.value != null &&
status.status != SyncStatus.error &&
client.prevBatch != null;
return TextField(
controller: controller.searchController,
focusNode: controller.searchFocusNode,
textInputAction: TextInputAction.search,
onChanged: (text) => controller.onSearchEnter(
text,
globalSearch: globalSearch,
),
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
contentPadding: EdgeInsets.zero,
hintText: hide
? L10n.of(context).searchChatsRooms
: status.calcLocalizedString(context),
hintStyle: TextStyle(
color: status.error != null
? Colors.orange
: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
prefixIcon: hide
? controller.isSearchMode
? 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,
),
)
: Container(
margin: const EdgeInsets.all(12),
width: 8,
height: 8,
child: Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
value: status.progress,
valueColor: status.error != null
? const AlwaysStoppedAnimation<Color>(
Colors.orange,
)
: null,
),
),
),
suffixIcon: controller.isSearchMode && globalSearch
? controller.isSearching
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: TextButton.icon(
onPressed: controller.setServer,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
textStyle: const TextStyle(fontSize: 12),
),
icon: const Icon(Icons.edit_outlined, size: 16),
label: Text(
controller.searchServer ??
Matrix.of(context).client.homeserver!.host,
maxLines: 2,
),
)
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
),
),
);
},
),
);
}
// return SliverAppBar(
// floating: true,
// toolbarHeight: 72,
// pinned: FluffyThemes.isColumnMode(context),
// scrolledUnderElevation: 0,
// backgroundColor: Colors.transparent,
// automaticallyImplyLeading: false,
// title: StreamBuilder(
// stream: client.onSyncStatus.stream,
// builder: (context, snapshot) {
// final status = client.onSyncStatus.value ??
// const SyncStatusUpdate(SyncStatus.waitingForResponse);
// final hide = client.onSync.value != null &&
// status.status != SyncStatus.error &&
// client.prevBatch != null;
// return TextField(
// controller: controller.searchController,
// focusNode: controller.searchFocusNode,
// textInputAction: TextInputAction.search,
// onChanged: (text) => controller.onSearchEnter(
// text,
// globalSearch: globalSearch,
// ),
// decoration: InputDecoration(
// filled: true,
// fillColor: theme.colorScheme.secondaryContainer,
// border: OutlineInputBorder(
// borderSide: BorderSide.none,
// borderRadius: BorderRadius.circular(99),
// ),
// contentPadding: EdgeInsets.zero,
// hintText: hide
// ? L10n.of(context).searchChatsRooms
// : status.calcLocalizedString(context),
// hintStyle: TextStyle(
// color: status.error != null
// ? Colors.orange
// : theme.colorScheme.onPrimaryContainer,
// fontWeight: FontWeight.normal,
// ),
// prefixIcon: hide
// ? controller.isSearchMode
// ? 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,
// ),
// )
// : Container(
// margin: const EdgeInsets.all(12),
// width: 8,
// height: 8,
// child: Center(
// child: CircularProgressIndicator.adaptive(
// strokeWidth: 2,
// value: status.progress,
// valueColor: status.error != null
// ? const AlwaysStoppedAnimation<Color>(
// Colors.orange,
// )
// : null,
// ),
// ),
// ),
// suffixIcon: controller.isSearchMode && globalSearch
// ? controller.isSearching
// ? const Padding(
// padding: EdgeInsets.symmetric(
// vertical: 10.0,
// horizontal: 12,
// ),
// child: SizedBox.square(
// dimension: 24,
// child: CircularProgressIndicator.adaptive(
// strokeWidth: 2,
// ),
// ),
// )
// : TextButton.icon(
// onPressed: controller.setServer,
// style: TextButton.styleFrom(
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(99),
// ),
// textStyle: const TextStyle(fontSize: 12),
// ),
// icon: const Icon(Icons.edit_outlined, size: 16),
// label: Text(
// controller.searchServer ??
// Matrix.of(context).client.homeserver!.host,
// maxLines: 2,
// ),
// )
// : SizedBox(
// width: 0,
// child: ClientChooserButton(controller),
// ),
// ),
// );
// },
// ),
// );
// }
@override
Size get preferredSize => const Size.fromHeight(56);
}
// @override
// Size get preferredSize => const Size.fromHeight(56);
// }
// Pangea#

View file

@ -2,14 +2,11 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pangea/chat_list/widgets/chat_list_view_body_wrapper.dart';
import 'package:fluffychat/pangea/onboarding/onboarding.dart';
import 'package:fluffychat/pangea/onboarding/onboarding_steps_enum.dart';
import 'package:fluffychat/widgets/navigation_rail.dart';
class ChatListView extends StatelessWidget {
final ChatListController controller;
@ -33,21 +30,20 @@ class ChatListView extends StatelessWidget {
},
child: Row(
children: [
if (FluffyThemes.isColumnMode(context) ||
AppConfig.displayNavigationRail) ...[
SpacesNavigationRail(
activeSpaceId: controller.activeSpaceId,
onGoToChats: controller.clearActiveSpace,
onGoToSpaceId: controller.setActiveSpace,
// #Pangea
clearActiveSpace: controller.clearActiveSpace,
// Pangea#
),
Container(
color: Theme.of(context).dividerColor,
width: 1,
),
],
// #Pangea
// if (FluffyThemes.isColumnMode(context) ||
// AppConfig.displayNavigationRail) ...[
// SpacesNavigationRail(
// activeSpaceId: controller.activeSpaceId,
// onGoToChats: controller.clearActiveSpace,
// onGoToSpaceId: controller.setActiveSpace,
// ),
// Container(
// color: Theme.of(context).dividerColor,
// width: 1,
// ),
// ],
// Pangea#
Expanded(
child: GestureDetector(
onTap: FocusManager.instance.primaryFocus?.unfocus,

View file

@ -1,231 +1,233 @@
import 'package:flutter/material.dart';
// #Pangea
// import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
// import 'package:go_router/go_router.dart';
// import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../utils/fluffy_share.dart';
import 'chat_list.dart';
// import 'package:fluffychat/config/themes.dart';
// import 'package:fluffychat/l10n/l10n.dart';
// import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
// import 'package:fluffychat/widgets/avatar.dart';
// import 'package:fluffychat/widgets/matrix.dart';
// import '../../utils/fluffy_share.dart';
// import 'chat_list.dart';
class ClientChooserButton extends StatelessWidget {
final ChatListController controller;
// class ClientChooserButton extends StatelessWidget {
// final ChatListController controller;
const ClientChooserButton(this.controller, {super.key});
// const ClientChooserButton(this.controller, {super.key});
List<PopupMenuEntry<Object>> _bundleMenuItems(BuildContext context) {
final matrix = Matrix.of(context);
final bundles = matrix.accountBundles.keys.toList()
..sort(
(a, b) => a!.isValidMatrixId == b!.isValidMatrixId
? 0
: a.isValidMatrixId && !b.isValidMatrixId
? -1
: 1,
);
return <PopupMenuEntry<Object>>[
PopupMenuItem(
value: SettingsAction.newGroup,
child: Row(
children: [
const Icon(Icons.group_add_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).createGroup),
],
),
),
PopupMenuItem(
value: SettingsAction.setStatus,
child: Row(
children: [
const Icon(Icons.edit_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).setStatus),
],
),
),
PopupMenuItem(
value: SettingsAction.invite,
child: Row(
children: [
Icon(Icons.adaptive.share_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).inviteContact),
],
),
),
PopupMenuItem(
value: SettingsAction.archive,
child: Row(
children: [
const Icon(Icons.archive_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).archive),
],
),
),
PopupMenuItem(
value: SettingsAction.settings,
child: Row(
children: [
const Icon(Icons.settings_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).settings),
],
),
),
const PopupMenuDivider(),
for (final bundle in bundles) ...[
if (matrix.accountBundles[bundle]!.length != 1 ||
matrix.accountBundles[bundle]!.single!.userID != bundle)
PopupMenuItem(
value: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
bundle!,
style: TextStyle(
color: Theme.of(context).textTheme.titleMedium!.color,
fontSize: 14,
),
),
const Divider(height: 1),
],
),
),
...matrix.accountBundles[bundle]!
.whereType<Client>()
.where((client) => client.isLogged())
.map(
(client) => PopupMenuItem(
value: client,
child: FutureBuilder<Profile?>(
future: client.fetchOwnProfile(),
builder: (context, snapshot) => Row(
children: [
Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
client.userID!.localpart,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Text(
snapshot.data?.displayName ??
client.userID!.localpart!,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => controller.editBundlesForAccount(
client.userID,
bundle,
),
),
],
),
),
),
),
],
PopupMenuItem(
value: SettingsAction.addAccount,
child: Row(
children: [
const Icon(Icons.person_add_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).addAccount),
],
),
),
];
}
// List<PopupMenuEntry<Object>> _bundleMenuItems(BuildContext context) {
// final matrix = Matrix.of(context);
// final bundles = matrix.accountBundles.keys.toList()
// ..sort(
// (a, b) => a!.isValidMatrixId == b!.isValidMatrixId
// ? 0
// : a.isValidMatrixId && !b.isValidMatrixId
// ? -1
// : 1,
// );
// return <PopupMenuEntry<Object>>[
// PopupMenuItem(
// value: SettingsAction.newGroup,
// child: Row(
// children: [
// const Icon(Icons.group_add_outlined),
// const SizedBox(width: 18),
// Text(L10n.of(context).createGroup),
// ],
// ),
// ),
// PopupMenuItem(
// value: SettingsAction.setStatus,
// child: Row(
// children: [
// const Icon(Icons.edit_outlined),
// const SizedBox(width: 18),
// Text(L10n.of(context).setStatus),
// ],
// ),
// ),
// PopupMenuItem(
// value: SettingsAction.invite,
// child: Row(
// children: [
// Icon(Icons.adaptive.share_outlined),
// const SizedBox(width: 18),
// Text(L10n.of(context).inviteContact),
// ],
// ),
// ),
// PopupMenuItem(
// value: SettingsAction.archive,
// child: Row(
// children: [
// const Icon(Icons.archive_outlined),
// const SizedBox(width: 18),
// Text(L10n.of(context).archive),
// ],
// ),
// ),
// PopupMenuItem(
// value: SettingsAction.settings,
// child: Row(
// children: [
// const Icon(Icons.settings_outlined),
// const SizedBox(width: 18),
// Text(L10n.of(context).settings),
// ],
// ),
// ),
// const PopupMenuDivider(),
// for (final bundle in bundles) ...[
// if (matrix.accountBundles[bundle]!.length != 1 ||
// matrix.accountBundles[bundle]!.single!.userID != bundle)
// PopupMenuItem(
// value: null,
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// mainAxisSize: MainAxisSize.min,
// children: [
// Text(
// bundle!,
// style: TextStyle(
// color: Theme.of(context).textTheme.titleMedium!.color,
// fontSize: 14,
// ),
// ),
// const Divider(height: 1),
// ],
// ),
// ),
// ...matrix.accountBundles[bundle]!
// .whereType<Client>()
// .where((client) => client.isLogged())
// .map(
// (client) => PopupMenuItem(
// value: client,
// child: FutureBuilder<Profile?>(
// future: client.fetchOwnProfile(),
// builder: (context, snapshot) => Row(
// children: [
// Avatar(
// mxContent: snapshot.data?.avatarUrl,
// name: snapshot.data?.displayName ??
// client.userID!.localpart,
// size: 32,
// ),
// const SizedBox(width: 12),
// Expanded(
// child: Text(
// snapshot.data?.displayName ??
// client.userID!.localpart!,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// const SizedBox(width: 12),
// IconButton(
// icon: const Icon(Icons.edit_outlined),
// onPressed: () => controller.editBundlesForAccount(
// client.userID,
// bundle,
// ),
// ),
// ],
// ),
// ),
// ),
// ),
// ],
// PopupMenuItem(
// value: SettingsAction.addAccount,
// child: Row(
// children: [
// const Icon(Icons.person_add_outlined),
// const SizedBox(width: 18),
// Text(L10n.of(context).addAccount),
// ],
// ),
// ),
// ];
// }
@override
Widget build(BuildContext context) {
final matrix = Matrix.of(context);
// @override
// Widget build(BuildContext context) {
// final matrix = Matrix.of(context);
var clientCount = 0;
matrix.accountBundles.forEach((key, value) => clientCount += value.length);
return FutureBuilder<Profile>(
future: matrix.client.isLogged() ? matrix.client.fetchOwnProfile() : null,
builder: (context, snapshot) => Material(
clipBehavior: Clip.hardEdge,
borderRadius: BorderRadius.circular(99),
color: Colors.transparent,
child: PopupMenuButton<Object>(
popUpAnimationStyle: FluffyThemes.isColumnMode(context)
? AnimationStyle.noAnimation
: null, // https://github.com/flutter/flutter/issues/167180
onSelected: (o) => _clientSelected(o, context),
itemBuilder: _bundleMenuItems,
child: Center(
child: Avatar(
mxContent: snapshot.data?.avatarUrl,
name:
snapshot.data?.displayName ?? matrix.client.userID?.localpart,
size: 32,
),
),
),
),
);
}
// var clientCount = 0;
// matrix.accountBundles.forEach((key, value) => clientCount += value.length);
// return FutureBuilder<Profile>(
// future: matrix.client.isLogged() ? matrix.client.fetchOwnProfile() : null,
// builder: (context, snapshot) => Material(
// clipBehavior: Clip.hardEdge,
// borderRadius: BorderRadius.circular(99),
// color: Colors.transparent,
// child: PopupMenuButton<Object>(
// popUpAnimationStyle: FluffyThemes.isColumnMode(context)
// ? AnimationStyle.noAnimation
// : null, // https://github.com/flutter/flutter/issues/167180
// onSelected: (o) => _clientSelected(o, context),
// itemBuilder: _bundleMenuItems,
// child: Center(
// child: Avatar(
// mxContent: snapshot.data?.avatarUrl,
// name:
// snapshot.data?.displayName ?? matrix.client.userID?.localpart,
// size: 32,
// ),
// ),
// ),
// ),
// );
// }
void _clientSelected(
Object object,
BuildContext context,
) async {
if (object is Client) {
controller.setActiveClient(object);
} else if (object is String) {
controller.setActiveBundle(object);
} else if (object is SettingsAction) {
switch (object) {
case SettingsAction.addAccount:
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).addAccount,
message: L10n.of(context).enableMultiAccounts,
okLabel: L10n.of(context).next,
cancelLabel: L10n.of(context).cancel,
);
if (consent != OkCancelResult.ok) return;
context.go('/rooms/settings/addaccount');
break;
case SettingsAction.newGroup:
context.go('/rooms/newgroup');
break;
case SettingsAction.invite:
FluffyShare.shareInviteLink(context);
break;
case SettingsAction.settings:
context.go('/rooms/settings');
break;
case SettingsAction.archive:
context.go('/rooms/archive');
break;
case SettingsAction.setStatus:
controller.setStatus();
break;
}
}
}
}
// void _clientSelected(
// Object object,
// BuildContext context,
// ) async {
// if (object is Client) {
// controller.setActiveClient(object);
// } else if (object is String) {
// controller.setActiveBundle(object);
// } else if (object is SettingsAction) {
// switch (object) {
// case SettingsAction.addAccount:
// final consent = await showOkCancelAlertDialog(
// context: context,
// title: L10n.of(context).addAccount,
// message: L10n.of(context).enableMultiAccounts,
// okLabel: L10n.of(context).next,
// cancelLabel: L10n.of(context).cancel,
// );
// if (consent != OkCancelResult.ok) return;
// context.go('/rooms/settings/addaccount');
// break;
// case SettingsAction.newGroup:
// context.go('/rooms/newgroup');
// break;
// case SettingsAction.invite:
// FluffyShare.shareInviteLink(context);
// break;
// case SettingsAction.settings:
// context.go('/rooms/settings');
// break;
// case SettingsAction.archive:
// context.go('/rooms/archive');
// break;
// case SettingsAction.setStatus:
// controller.setStatus();
// break;
// }
// }
// }
// }
enum SettingsAction {
addAccount,
newGroup,
setStatus,
invite,
settings,
archive,
}
// enum SettingsAction {
// addAccount,
// newGroup,
// setStatus,
// invite,
// settings,
// archive,
// }
// Pangea#

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,14 @@ class ChatPermissionsSettings extends StatefulWidget {
}
class ChatPermissionsSettingsController extends State<ChatPermissionsSettings> {
String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
// #Pangea
// String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
String? get roomId {
final pathParameters = GoRouterState.of(context).pathParameters;
return pathParameters['roomid'] ?? pathParameters['spaceid'];
}
// Pangea#
void editPowerLevel(
BuildContext context,
String key,

View file

@ -214,7 +214,7 @@ class NewGroupController extends State<NewGroup> {
GoogleAnalytics.createClass(room.name, spaceCode);
}
context.go("/rooms?spaceId=$spaceId");
context.go("/rooms/spaces/$spaceId/details");
// Pangea#
}

View file

@ -7,14 +7,12 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/navigation_rail.dart';
import '../../widgets/mxc_image_viewer.dart';
import 'settings.dart';
@ -43,19 +41,17 @@ class SettingsView extends StatelessWidget {
children: [
// #Pangea
// if (FluffyThemes.isColumnMode(context)) ...[
if (FluffyThemes.isColumnMode(context) ||
AppConfig.displayNavigationRail) ...[
// Pangea#
SpacesNavigationRail(
activeSpaceId: null,
onGoToChats: () => context.go('/rooms'),
onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'),
),
Container(
color: Theme.of(context).dividerColor,
width: 1,
),
],
// SpacesNavigationRail(
// activeSpaceId: null,
// onGoToChats: () => context.go('/rooms'),
// onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'),
// ),
// Container(
// color: Theme.of(context).dividerColor,
// width: 1,
// ),
// ],
// Pangea#
Expanded(
child: Scaffold(
// #Pangea

View file

@ -27,7 +27,13 @@ class EmotesSettings extends StatefulWidget {
}
class EmotesSettingsController extends State<EmotesSettings> {
String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
// #Pangea
// String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
String? get roomId {
final pathParameters = GoRouterState.of(context).pathParameters;
return pathParameters['roomid'] ?? pathParameters['spaceid'];
}
// Pangea#
Room? get room =>
roomId != null ? Matrix.of(context).client.getRoomById(roomId!) : null;

View file

@ -13,7 +13,14 @@ class MultipleEmotesSettings extends StatefulWidget {
}
class MultipleEmotesSettingsController extends State<MultipleEmotesSettings> {
String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
// #Pangea
// String? get roomId => GoRouterState.of(context).pathParameters['roomid'];
String? get roomId {
final pathParameters = GoRouterState.of(context).pathParameters;
return pathParameters['roomid'] ?? pathParameters['spaceid'];
}
// Pangea#
@override
Widget build(BuildContext context) => MultipleEmotesSettingsView(this);
}

View file

@ -39,14 +39,14 @@ class ActivityPlanCard extends StatelessWidget {
final ids = await controller.launchToSpace();
ids.length == 1
? context.go("/rooms/${ids.first}")
: context.go("/rooms?spaceId=${controller.room.id}");
? context.go("/rooms/spaces/${controller.room.id}/${ids.first}")
: context.go("/rooms/spaces/${controller.room.id}/details");
Navigator.of(context).pop();
},
);
if (!resp.isError) {
context.go("/rooms?spaceId=${controller.room.id}");
context.go("/rooms/spaces/${controller.room.id}/details");
}
}

View file

@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_plan_repo.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/join_rule_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
@ -33,6 +34,9 @@ class ActivityPlannerBuilder extends StatefulWidget {
final String? initialFilename;
final Room room;
final bool enabledEdits;
final bool enableMultiLaunch;
final Widget Function(ActivityPlannerBuilderState) builder;
const ActivityPlannerBuilder({
@ -41,6 +45,8 @@ class ActivityPlannerBuilder extends StatefulWidget {
this.initialFilename,
required this.room,
required this.builder,
this.enabledEdits = false,
this.enableMultiLaunch = false,
});
@override
@ -334,9 +340,13 @@ class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
Future<String> _launchActivityRoom(int index) async {
await updateImageURL();
final roomID = await Matrix.of(context).client.createGroupChat(
final roomID = await Matrix.of(context).client.createRoom(
creationContent: {
'type':
"${PangeaRoomTypes.activitySession}:${updatedActivity.bookmarkId}",
},
visibility: Visibility.private,
groupName: "${updatedActivity.title} ${index + 1}",
name: "${updatedActivity.title} ${index + 1}",
initialState: [
if (imageURL != null)
StateEvent(
@ -356,7 +366,6 @@ class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
],
),
],
enableEncryption: false,
);
Room? activityRoom = room.client.getRoomById(roomID);

View file

@ -137,7 +137,7 @@ class ActivityPlannerPageState extends State<ActivityPlannerPage> {
),
selected: false,
onSelected: (_) => context.go(
'/rooms/${widget.roomID}/details/planner/generator',
'/rooms/spaces/${widget.roomID}/details/planner/generator',
),
),
],

View file

@ -14,6 +14,8 @@ import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/courses/course_repo.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -28,10 +30,28 @@ class ActivityFinishedStatusMessage extends StatelessWidget {
Map<String, ActivityRole> get _roles =>
controller.room.activityPlan?.roles ?? {};
Future<void> _archiveToAnalytics() async {
Future<void> _archiveToAnalytics(BuildContext context) async {
await controller.room.archiveActivity();
await MatrixState.pangeaController.putAnalytics
.sendActivityAnalytics(controller.room.id);
final courseParent = controller.room.courseParent;
if (courseParent?.coursePlan == null) return;
final coursePlan = await CourseRepo.get(
courseParent!.coursePlan!.uuid,
);
if (coursePlan == null) {
throw L10n.of(context).noCourseFound;
}
final activityId = controller.room.activityPlan!.bookmarkId;
final topicId = coursePlan.topicID(activityId);
if (topicId == null) {
throw L10n.of(context).activityNotFoundForCourse;
}
await courseParent.finishCourseActivity(activityId, topicId);
}
List<ActivityRoleModel> get _rolesWithSummaries {
@ -226,7 +246,7 @@ class ActivityFinishedStatusMessage extends StatelessWidget {
onPressed: () async {
final resp = await showFutureLoadingDialog(
context: context,
future: _archiveToAnalytics,
future: () => _archiveToAnalytics(context),
);
if (!resp.isError) {

View file

@ -12,8 +12,10 @@ import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_mo
import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
@ -322,4 +324,16 @@ extension ActivityRoomExtension on Room {
}
bool get isHiddenActivityRoom => ownRole?.isArchived ?? false;
Room? get courseParent => pangeaSpaceParents.firstWhereOrNull(
(parent) => parent.coursePlan != null,
);
bool get isActivitySession =>
getState(EventTypes.RoomCreate)
?.content
.tryGet<String>('type')
?.startsWith(PangeaRoomTypes.activitySession) ==
true ||
activityPlan != null;
}

View file

@ -4,7 +4,6 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class ActivitySuggestionCard extends StatelessWidget {
@ -13,12 +12,19 @@ class ActivitySuggestionCard extends StatelessWidget {
final double width;
final double height;
final double? fontSize;
final double? fontSizeSmall;
final double? iconSize;
const ActivitySuggestionCard({
super.key,
required this.controller,
required this.onPressed,
required this.width,
required this.height,
this.fontSize,
this.fontSizeSmall,
this.iconSize,
});
ActivityPlanModel get activity => controller.updatedActivity;
@ -26,40 +32,26 @@ class ActivitySuggestionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PressableButton(
onPressed: onPressed,
borderRadius: BorderRadius.circular(24.0),
color: theme.brightness == Brightness.dark
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
colorFactor: theme.brightness == Brightness.dark ? 0.6 : 0.2,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
),
height: height,
width: width,
child: Stack(
alignment: Alignment.topCenter,
children: [
Container(
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onPressed,
child: SizedBox(
height: height,
width: width,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24.0),
),
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: width,
width: width,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
height: width,
width: width,
child: activity.imageURL != null
? activity.imageURL!.startsWith("mxc")
? MxcImage(
@ -77,104 +69,70 @@ class ActivitySuggestionCard extends StatelessWidget {
errorWidget: (context, url, error) =>
const SizedBox(),
fit: BoxFit.cover,
width: width,
height: width,
)
: null,
: const SizedBox(),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Row(
children: [
Flexible(
child: Text(
activity.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activity.title,
style: TextStyle(
fontSize: fontSize,
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 8.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
const Icon(
Icons.group_outlined,
size: 12.0,
),
Text(
"${activity.req.numberOfParticipants}",
style: theme.textTheme.labelSmall,
),
],
),
),
if (activity.req.mode.isNotEmpty)
Flexible(
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 8.0,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
if (activity.req.mode.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
activity.req.mode,
style: theme.textTheme.labelSmall,
style: fontSizeSmall != null
? TextStyle(fontSize: fontSizeSmall)
: theme.textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4.0,
children: [
Icon(
Icons.group_outlined,
size: iconSize ?? 12.0,
),
Text(
"${activity.req.numberOfParticipants}",
style: fontSizeSmall != null
? TextStyle(fontSize: fontSizeSmall)
: theme.textTheme.labelSmall,
),
],
),
),
],
),
],
],
),
],
),
),
),
),
],
),
Positioned(
top: 4.0,
right: 4.0,
child: IconButton(
icon: Icon(
controller.isBookmarked ? Icons.save : Icons.save_outlined,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: controller.toggleBookmarkedActivity,
style: IconButton.styleFrom(
backgroundColor: Theme.of(context)
.colorScheme
.primaryContainer
.withAlpha(180),
),
],
),
),
],
),
),
),
);

View file

@ -74,8 +74,9 @@ class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
final ids = await widget.controller.launchToSpace();
ids.length == 1
? context.go("/rooms/${ids.first}")
: context.go("/rooms?spaceId=${widget.controller.room.id}");
? context
.go("/rooms/spaces/${widget.controller.room.id}/${ids.first}")
: context.go("/rooms/spaces/${widget.controller.room.id}/details");
Navigator.of(context).pop();
} catch (e, s) {
_launchError = L10n.of(context).errorLaunchActivityMessage;

View file

@ -241,23 +241,24 @@ class _ActivitySuggestionBaseContent extends StatelessWidget {
Row(
spacing: 12.0,
children: [
Expanded(
child: ElevatedButton(
style: controller.buttonStyle,
onPressed: activityController.startEditing,
child: Row(
children: [
const Icon(Icons.edit),
Expanded(
child: Text(
L10n.of(context).edit,
textAlign: TextAlign.center,
if (activityController.widget.enabledEdits)
Expanded(
child: ElevatedButton(
style: controller.buttonStyle,
onPressed: activityController.startEditing,
child: Row(
children: [
const Icon(Icons.edit),
Expanded(
child: Text(
L10n.of(context).edit,
textAlign: TextAlign.center,
),
),
),
],
],
),
),
),
),
if (controller.widget.replaceActivity != null)
Expanded(
child: ElevatedButton(
@ -287,9 +288,11 @@ class _ActivitySuggestionBaseContent extends StatelessWidget {
style: controller.buttonStyle,
// onPressed: _launchActivity,
onPressed: () {
activityController.setLaunchState(
ActivityLaunchState.launching,
);
!activityController.widget.enableMultiLaunch
? controller.launchActivity()
: activityController.setLaunchState(
ActivityLaunchState.launching,
);
},
child: Row(
spacing: 12.0,
@ -393,7 +396,7 @@ class _ActivitySuggestionEditContent extends StatelessWidget {
child: TextFormField(
controller: activityController.participantsController,
decoration: InputDecoration(
labelText: L10n.of(context).classRoster,
labelText: L10n.of(context).participants,
),
maxLines: 1,
keyboardType: TextInputType.number,

View file

@ -1,9 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_page/activity_archive.dart';
@ -12,7 +8,6 @@ import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators
import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/navigation_rail.dart';
class AnalyticsPageView extends StatelessWidget {
final AnalyticsPageState controller;
@ -23,70 +18,51 @@ class AnalyticsPageView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsetsGeometry.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LearningProgressIndicators(
selected: controller.selectedIndicator,
canSelect:
controller.selectedIndicator != ProgressIndicatorEnum.level,
),
Expanded(
child: StreamBuilder(
stream: MatrixState
.pangeaController.getAnalytics.analyticsStream.stream,
builder: (context, _) {
if (controller.selectedIndicator ==
ProgressIndicatorEnum.level) {
return const LevelDialogContent();
} else if (controller.selectedIndicator ==
ProgressIndicatorEnum.morphsUsed) {
return AnalyticsPopupWrapper(
constructZoom: controller.widget.constructZoom,
view: ConstructTypeEnum.morph,
);
} else if (controller.selectedIndicator ==
ProgressIndicatorEnum.wordsUsed) {
return AnalyticsPopupWrapper(
constructZoom: controller.widget.constructZoom,
view: ConstructTypeEnum.vocab,
);
} else if (controller.selectedIndicator ==
ProgressIndicatorEnum.activities) {
return const ActivityArchive();
}
return Row(
children: [
if (!isColumnMode && AppConfig.displayNavigationRail) ...[
SpacesNavigationRail(
activeSpaceId: null,
onGoToChats: () => context.go('/rooms'),
onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'),
),
Container(
color: Theme.of(context).dividerColor,
width: 1,
),
],
Expanded(
child: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsetsGeometry.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LearningProgressIndicators(
selected: controller.selectedIndicator,
canSelect: controller.selectedIndicator !=
ProgressIndicatorEnum.level,
),
Expanded(
child: StreamBuilder(
stream: MatrixState.pangeaController.getAnalytics
.analyticsStream.stream,
builder: (context, _) {
if (controller.selectedIndicator ==
ProgressIndicatorEnum.level) {
return const LevelDialogContent();
} else if (controller.selectedIndicator ==
ProgressIndicatorEnum.morphsUsed) {
return AnalyticsPopupWrapper(
constructZoom: controller.widget.constructZoom,
view: ConstructTypeEnum.morph,
);
} else if (controller.selectedIndicator ==
ProgressIndicatorEnum.wordsUsed) {
return AnalyticsPopupWrapper(
constructZoom: controller.widget.constructZoom,
view: ConstructTypeEnum.vocab,
);
} else if (controller.selectedIndicator ==
ProgressIndicatorEnum.activities) {
return const ActivityArchive();
}
return const SizedBox();
},
),
),
],
return const SizedBox();
},
),
),
),
],
),
),
],
),
);
}
}

View file

@ -57,7 +57,11 @@ class RoomDefaults {
},
);
static StateEvent defaultSpacePowerLevels(String userID) => StateEvent(
static StateEvent defaultSpacePowerLevels(
String userID, {
int spaceChild = 50,
}) =>
StateEvent(
type: EventTypes.RoomPowerLevels,
stateKey: '',
content: {
@ -68,7 +72,7 @@ class RoomDefaults {
"events": {
"m.room.power_levels": 100,
"m.room.join_rules": 100,
"m.space.child": 50,
"m.space.child": spaceChild,
},
"events_default": 0,
"state_default": 50,

View file

@ -28,7 +28,9 @@ Future<void> showInviteDialog(Room room, BuildContext context) async {
if (acceptInvite == OkCancelResult.ok) {
await room.join();
context.go(
room.isSpace ? "/rooms?spaceId=${room.id}" : "/rooms/${room.id}",
room.isSpace
? "/rooms/spaces/${room.id}/details"
: "/rooms/${room.id}",
);
return room.id;
} else if (acceptInvite == OkCancelResult.cancel) {
@ -38,7 +40,7 @@ Future<void> showInviteDialog(Room room, BuildContext context) async {
);
if (!resp.isError && resp.result is String) {
context.go("/rooms?spaceId=${resp.result}");
context.go("/rooms/spaces/${resp.result}/details");
}
}
@ -48,7 +50,7 @@ void chatListHandleSpaceTap(
Room space,
) {
void setActiveSpaceAndCloseChat() {
context.go("/rooms?spaceId=${space.id}");
context.go("/rooms/spaces/${space.id}/details");
}
void autoJoin(Room space) {

View file

@ -1,3 +1,4 @@
class PangeaRoomTypes {
static const analytics = 'p.analytics';
static const activitySession = 'p.activity.session';
}

View file

@ -0,0 +1,271 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_details/chat_details.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/pages/room_details_buttons.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/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ChatDetailsButtonRow extends StatefulWidget {
final ChatDetailsController controller;
final Room room;
const ChatDetailsButtonRow({
super.key,
required this.controller,
required this.room,
});
@override
State<ChatDetailsButtonRow> createState() => ChatDetailsButtonRowState();
}
class ChatDetailsButtonRowState extends State<ChatDetailsButtonRow> {
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 _buttonHeight = 84.0;
final double _miniButtonWidth = 50.0;
Room get room => widget.room;
List<ButtonDetails> _buttons(BuildContext context) {
final L10n l10n = L10n.of(context);
return [
ButtonDetails(
title: l10n.permissions,
icon: const Icon(Icons.edit_attributes_outlined, size: 30.0),
onPressed: () => context.go('/rooms/${room.id}/details/permissions'),
enabled: room.isRoomAdmin && !room.isDirectChat,
showInMainView: false,
),
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,
),
),
),
ButtonDetails(
title: l10n.invite,
icon: const Icon(Icons.person_add_outlined, size: 30.0),
onPressed: () {
String filter = 'knocking';
if (room.getParticipants([Membership.knock]).isEmpty) {
filter = room.pangeaSpaceParents.isNotEmpty ? 'space' : 'contacts';
}
context.go('/rooms/${room.id}/details/invite?filter=$filter');
},
enabled: room.canInvite && !room.isDirectChat,
),
ButtonDetails(
title: l10n.download,
icon: const Icon(Icons.download_outlined, size: 30.0),
onPressed: widget.controller.downloadChatAction,
visible: kIsWeb,
enabled: room.ownPowerLevel >= 50,
showInMainView: false,
),
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.isDirectChat || room.botOptions != null,
enabled: room.canInvite,
),
ButtonDetails(
title: l10n.chatCapacity,
icon: const Icon(Icons.reduce_capacity, size: 30.0),
onPressed: widget.controller.setRoomCapacity,
enabled: !room.isDirectChat && room.canSendDefaultStates,
showInMainView: false,
),
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: L10n.of(context).leaveRoomDescription,
isDestructive: true,
);
if (confirmed != OkCancelResult.ok) return;
final resp = await showFutureLoadingDialog(
context: context,
future: room.leave,
);
if (!resp.isError) {
context.go("/rooms");
}
},
enabled: room.membership == Membership.join,
showInMainView: false,
),
ButtonDetails(
title: l10n.delete,
icon: const Icon(Icons.delete_outline, size: 30.0),
onPressed: () async {
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).delete,
cancelLabel: L10n.of(context).cancel,
isDestructive: true,
message: 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");
},
enabled: room.isRoomAdmin && !room.isDirectChat,
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 / 120.0).floor() - 1;
final mini = fullButtonCapacity < 4;
final List<ButtonDetails> mainViewButtons =
buttons.where((button) => button.showInMainView).toList();
final List<ButtonDetails> otherButtons =
buttons.where((button) => !button.showInMainView).toList();
return Row(
spacing: FluffyThemes.isColumnMode(context) ? 12.0 : 0.0,
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(mainViewButtons.length + 1, (index) {
if (index == mainViewButtons.length) {
if (otherButtons.isEmpty) {
return const SizedBox();
}
return Expanded(
child: PopupMenuButton(
useRootNavigator: true,
itemBuilder: (context) {
return otherButtons
.map(
(button) => PopupMenuItem(
value: button,
onTap: button.enabled ? button.onPressed : null,
enabled: button.enabled,
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),
),
height: mini ? _miniButtonWidth : _buttonHeight,
),
),
);
}
final button = mainViewButtons[index];
return Expanded(
child: RoomDetailsButton(
mini: mini,
buttonDetails: button,
height: mini ? _miniButtonWidth : _buttonHeight,
),
);
}),
);
},
),
);
}
}

View file

@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pangea/chat_settings/pages/chat_details_button_row.dart';
import 'package:fluffychat/pangea/chat_settings/pages/room_participants_widget.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.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/avatar.dart';
class ChatDetailsContent extends StatelessWidget {
final ChatDetailsController controller;
final Room room;
const ChatDetailsContent(this.controller, this.room, {super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: 2,
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
final theme = Theme.of(context);
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
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,
disabledForegroundColor:
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/invite?filter=participants',
),
icon: const Icon(
Icons.group_outlined,
size: 14,
),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.secondary,
disabledForegroundColor:
theme.colorScheme.onSurface,
),
label: Text(
L10n.of(context).countParticipants(
room.getParticipants().length,
),
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: ChatDetailsButtonRow(
controller: controller,
room: room,
),
),
],
);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: RoomParticipantsSection(room: room),
);
},
);
}
}

View file

@ -1,882 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.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/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_details/chat_details.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/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: <Widget>[
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,
disabledForegroundColor:
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/invite?filter=participants',
),
icon: const Icon(
Icons.group_outlined,
size: 14,
),
style: TextButton.styleFrom(
foregroundColor:
theme.colorScheme.secondary,
disabledForegroundColor:
theme.colorScheme.onSurface,
),
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<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 _buttonHeight = 84.0;
final double _miniButtonWidth = 50.0;
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, size: 30.0),
onPressed: () => context.go("/rooms/${room.id}/details/planner"),
visible: room.isSpace,
enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan) &&
room.isSpace,
),
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,
showInMainView: false,
),
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: () {
String filter = 'knocking';
if (room.getParticipants([Membership.knock]).isEmpty) {
filter = room.pangeaSpaceParents.isNotEmpty ? 'space' : 'contacts';
}
context.go('/rooms/${room.id}/details/invite?filter=$filter');
},
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.spaceAnalytics,
icon: const Icon(Icons.bar_chart, size: 30.0),
onPressed: () => context.go('/rooms/${room.id}/details/analytics'),
visible: room.isSpace,
enabled: room.isSpace && room.isRoomAdmin,
showInMainView: true,
),
ButtonDetails(
title: l10n.download,
icon: const Icon(Icons.download_outlined, size: 30.0),
onPressed: widget.controller.downloadChatAction,
visible: room.ownPowerLevel >= 50 && !room.isSpace && kIsWeb,
showInMainView: false,
),
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.isSpace &&
(!room.isDirectChat || room.botOptions != null) &&
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,
showInMainView: false,
),
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<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.isRoomAdmin && !room.isDirectChat,
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 / 120.0).floor() - 1;
final mini = fullButtonCapacity < 4;
final List<ButtonDetails> mainViewButtons =
buttons.where((button) => button.showInMainView).toList();
final List<ButtonDetails> otherButtons =
buttons.where((button) => !button.showInMainView).toList();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(mainViewButtons.length + 1, (index) {
if (index == mainViewButtons.length) {
if (otherButtons.isEmpty) {
return const SizedBox();
}
return Expanded(
child: 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,
),
height: mini ? _miniButtonWidth : _buttonHeight,
),
),
);
}
final button = mainViewButtons[index];
return Expanded(
child: RoomDetailsButton(
mini: mini,
buttonDetails: button,
height: mini ? _miniButtonWidth : _buttonHeight,
),
);
}),
);
},
),
);
}
}
class RoomDetailsButton extends StatelessWidget {
final bool mini;
final double height;
final ButtonDetails buttonDetails;
const RoomDetailsButton({
super.key,
required this.buttonDetails,
required this.mini,
required this.height,
});
@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(
alignment: Alignment.center,
height: height,
decoration: BoxDecoration(
color: hovered
? Theme.of(context)
.colorScheme
.primary
.withAlpha(50)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
padding: EdgeInsets.all(mini ? 6 : 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 _spacing = 15.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LoadParticipantsUtil(
space: room,
builder: (participantsLoader) {
final filteredParticipants =
participantsLoader.filteredParticipants("");
final originalLeaders = filteredParticipants.take(3).toList();
filteredParticipants.sort((a, b) {
// always sort bot to the end
final aIsBot = a.id == BotName.byEnvironment;
final bIsBot = b.id == BotName.byEnvironment;
if (aIsBot && !bIsBot) {
return 1;
} else if (bIsBot && !aIsBot) {
return -1;
}
// put knocking users at the front
if (a.membership == Membership.knock &&
b.membership != Membership.knock) {
return -1;
} else if (b.membership == Membership.knock &&
a.membership != Membership.knock) {
return 1;
}
// then invited users
if (a.membership == Membership.invite &&
b.membership != Membership.invite) {
return -1;
} else if (b.membership == Membership.invite &&
a.membership != Membership.invite) {
return 1;
}
// then admins
if (a.powerLevel == 100 && b.powerLevel != 100) {
return -1;
} else if (b.powerLevel == 100 && a.powerLevel != 100) {
return 1;
}
return 0;
});
return Wrap(
spacing: _spacing,
runSpacing: _spacing,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
...filteredParticipants.mapIndexed((index, user) {
final permissionBatch = user.powerLevel >= 100
? L10n.of(context).admin
: user.powerLevel >= 50
? L10n.of(context).moderator
: '';
final membershipBatch = switch (user.membership) {
Membership.ban => null,
Membership.invite => L10n.of(context).invited,
Membership.join => null,
Membership.knock => L10n.of(context).knocking,
Membership.leave => null,
};
final publicProfile = participantsLoader.getAnalyticsProfile(
user.id,
);
final leaderIndex = originalLeaders.indexOf(user);
LinearGradient? gradient;
if (leaderIndex != -1) {
gradient = leaderIndex.leaderboardGradient;
if (user.id == BotName.byEnvironment ||
publicProfile == null ||
publicProfile.level == null) {
gradient = null;
}
}
return SizedBox(
width: _width,
child: Opacity(
opacity: user.membership == Membership.join ? 1.0 : 0.5,
child: Column(
spacing: 4.0,
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,
presenceOffset: const Offset(0, 0),
presenceSize: 18.0,
),
),
),
);
},
),
],
),
Text(
user.calcDisplayname(),
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
Container(
height: 20.0,
alignment: Alignment.center,
child: LevelDisplayName(
userId: user.id,
textStyle: theme.textTheme.labelSmall,
),
),
Container(
height: 24.0,
alignment: Alignment.center,
child: membershipBatch != null
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
child: Text(
membershipBatch,
style: theme.textTheme.labelSmall?.copyWith(
color:
theme.colorScheme.onSecondaryContainer,
),
),
)
: permissionBatch.isNotEmpty
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: user.powerLevel >= 100
? theme.colorScheme.tertiary
: theme.colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
child: Text(
permissionBatch,
style:
theme.textTheme.labelSmall?.copyWith(
color: user.powerLevel >= 100
? theme.colorScheme.onTertiary
: theme.colorScheme
.onTertiaryContainer,
),
),
)
: null,
),
],
),
),
);
}),
],
);
},
);
}
}

View file

@ -127,7 +127,7 @@ class PangeaInvitationSelectionController
case InvitationFilter.public:
return l10n.public;
case InvitationFilter.participants:
return l10n.classRoster;
return l10n.participants;
}
}

View file

@ -72,7 +72,7 @@ class PangeaInvitationSelectionView extends StatelessWidget {
],
),
onPressed: () => context.go(
room.isSpace ? "/rooms?spaceId=${room.id}" : "/rooms/${room.id}",
room.isSpace ? "/rooms/spaces/${room.id}/details" : "/rooms/${room.id}",
),
);

View file

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pangea/chat_settings/pages/chat_details_content.dart';
import 'package:fluffychat/pangea/chat_settings/pages/space_details_content.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
class PangeaRoomDetailsView extends StatelessWidget {
final ChatDetailsController controller;
const PangeaRoomDetailsView(this.controller, {super.key});
@override
Widget build(BuildContext 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),
),
);
}
final isColumnMode = FluffyThemes.isColumnMode(context);
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();
return Scaffold(
appBar: room.isSpace
? null
: AppBar(
leading: controller.widget.embeddedCloseButton ??
const Center(child: BackButton()),
),
body: Padding(
padding: EdgeInsetsGeometry.symmetric(
vertical: isColumnMode ? 30.0 : 12.0,
horizontal: isColumnMode ? 50.0 : 8.0,
),
child: MaxWidthBody(
maxWidth: 900,
showBorder: false,
innerPadding: const EdgeInsets.symmetric(horizontal: 16.0),
withScrolling: !room.isSpace,
child: room.isSpace
? SpaceDetailsContent(controller, room)
: ChatDetailsContent(controller, room),
),
),
);
},
);
}
}

View file

@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/chat_settings/pages/space_details_content.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
class ButtonDetails {
final String title;
final String? description;
final Widget icon;
final VoidCallback? onPressed;
final bool visible;
final bool enabled;
final bool showInMainView;
final bool desctructive;
final SpaceSettingsTabs? tab;
const ButtonDetails({
required this.title,
this.description,
required this.icon,
this.visible = true,
this.enabled = true,
this.onPressed,
this.showInMainView = true,
this.desctructive = false,
this.tab,
});
}
class RoomDetailsButton extends StatelessWidget {
final bool mini;
final double height;
final ButtonDetails buttonDetails;
final bool selected;
const RoomDetailsButton({
super.key,
required this.buttonDetails,
required this.mini,
required this.height,
this.selected = false,
});
@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(
alignment: Alignment.center,
height: height,
decoration: BoxDecoration(
color: hovered || selected
? Theme.of(context)
.colorScheme
.primaryContainer
.withAlpha(200)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
padding: EdgeInsets.all(mini ? 6 : 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),
),
],
),
),
),
);
},
),
),
),
),
);
}
}

View file

@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart';
class RoomParticipantsSection extends StatelessWidget {
final Room room;
const RoomParticipantsSection({
required this.room,
super.key,
});
final double _width = 100.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LoadParticipantsUtil(
space: room,
builder: (participantsLoader) {
final filteredParticipants = participantsLoader.sortedParticipants();
final originalLeaders = filteredParticipants.take(3).toList();
filteredParticipants.sort((a, b) {
// always sort bot to the end
final aIsBot = a.id == BotName.byEnvironment;
final bIsBot = b.id == BotName.byEnvironment;
if (aIsBot && !bIsBot) {
return 1;
} else if (bIsBot && !aIsBot) {
return -1;
}
// put knocking users at the front
if (a.membership == Membership.knock &&
b.membership != Membership.knock) {
return -1;
} else if (b.membership == Membership.knock &&
a.membership != Membership.knock) {
return 1;
}
// then invited users
if (a.membership == Membership.invite &&
b.membership != Membership.invite) {
return -1;
} else if (b.membership == Membership.invite &&
a.membership != Membership.invite) {
return 1;
}
// then admins
if (a.powerLevel == 100 && b.powerLevel != 100) {
return -1;
} else if (b.powerLevel == 100 && a.powerLevel != 100) {
return 1;
}
return 0;
});
return Wrap(
spacing: 8.0,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [...filteredParticipants, null].mapIndexed((index, user) {
if (user == null) {
return room.canInvite
? MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => context.go(
"/rooms/${room.id}/details/invite",
),
child: HoverBuilder(
builder: (context, hovered) {
return Container(
decoration: BoxDecoration(
color: hovered
? Theme.of(context)
.colorScheme
.primary
.withAlpha(50)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
padding:
const EdgeInsets.symmetric(vertical: 12.0),
width: _width,
child: Column(
spacing: 4.0,
children: [
const Padding(
padding: EdgeInsets.all(12.0),
child: Icon(
Icons.person_add_outlined,
size: 50.0,
),
),
Text(
L10n.of(context).invite,
style: const TextStyle(fontSize: 16.0),
),
],
),
);
},
),
),
)
: const SizedBox();
}
final permissionBatch = user.powerLevel >= 100
? L10n.of(context).admin
: user.powerLevel >= 50
? L10n.of(context).moderator
: '';
final membershipBatch = switch (user.membership) {
Membership.ban => null,
Membership.invite => L10n.of(context).invited,
Membership.join => null,
Membership.knock => L10n.of(context).knocking,
Membership.leave => null,
};
final publicProfile = participantsLoader.getAnalyticsProfile(
user.id,
);
final leaderIndex = originalLeaders.indexOf(user);
LinearGradient? gradient;
if (leaderIndex != -1) {
gradient = leaderIndex.leaderboardGradient;
if (user.id == BotName.byEnvironment ||
publicProfile == null ||
publicProfile.level == null) {
gradient = null;
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: SizedBox(
width: _width,
child: Opacity(
opacity: user.membership == Membership.join ? 1.0 : 0.5,
child: Column(
spacing: 4.0,
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,
presenceOffset: const Offset(0, 0),
presenceSize: 18.0,
),
),
),
);
},
),
],
),
Text(
user.calcDisplayname(),
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
Container(
height: 20.0,
alignment: Alignment.center,
child: LevelDisplayName(
userId: user.id,
textStyle: theme.textTheme.labelSmall,
),
),
Container(
height: 24.0,
alignment: Alignment.center,
child: membershipBatch != null
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
child: Text(
membershipBatch,
style: theme.textTheme.labelSmall?.copyWith(
color:
theme.colorScheme.onSecondaryContainer,
),
),
)
: permissionBatch.isNotEmpty
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: user.powerLevel >= 100
? theme.colorScheme.tertiary
: theme.colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
child: Text(
permissionBatch,
style:
theme.textTheme.labelSmall?.copyWith(
color: user.powerLevel >= 100
? theme.colorScheme.onTertiary
: theme.colorScheme
.onTertiaryContainer,
),
),
)
: null,
),
],
),
),
),
);
}).toList(),
);
},
);
}
}

View file

@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pangea/chat_settings/pages/room_details_buttons.dart';
import 'package:fluffychat/pangea/chat_settings/pages/space_details_content.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SpaceDetailsButtonRow extends StatefulWidget {
final SpaceSettingsTabs? selectedTab;
final Function(SpaceSettingsTabs) onTabSelected;
final List<ButtonDetails> buttons;
final ChatDetailsController controller;
final Room room;
const SpaceDetailsButtonRow({
super.key,
required this.selectedTab,
required this.onTabSelected,
required this.buttons,
required this.controller,
required this.room,
});
@override
State<SpaceDetailsButtonRow> createState() => SpaceDetailsButtonRowState();
}
class SpaceDetailsButtonRowState extends State<SpaceDetailsButtonRow> {
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 _buttonHeight = 84.0;
final double _miniButtonWidth = 50.0;
Room get room => widget.room;
@override
Widget build(BuildContext context) {
final buttons = widget.buttons
.where(
(button) => button.visible,
)
.toList();
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final fullButtonCapacity = (availableWidth / 120.0).floor() - 1;
final mini = fullButtonCapacity < 4;
final List<ButtonDetails> mainViewButtons =
buttons.where((button) => button.showInMainView).toList();
final List<ButtonDetails> otherButtons =
buttons.where((button) => !button.showInMainView).toList();
return Row(
spacing: FluffyThemes.isColumnMode(context) ? 12.0 : 0.0,
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(mainViewButtons.length + 1, (index) {
if (index == mainViewButtons.length) {
if (otherButtons.isEmpty) {
return const SizedBox();
}
return Expanded(
child: RoomDetailsButton(
mini: mini,
buttonDetails: ButtonDetails(
title: L10n.of(context).more,
icon: const Icon(Icons.more_horiz_outlined),
onPressed: () =>
widget.onTabSelected(SpaceSettingsTabs.more),
),
height: mini ? _miniButtonWidth : _buttonHeight,
selected: widget.selectedTab == SpaceSettingsTabs.more,
),
);
}
final button = mainViewButtons[index];
return Expanded(
child: RoomDetailsButton(
mini: mini,
buttonDetails: button,
height: mini ? _miniButtonWidth : _buttonHeight,
selected: widget.selectedTab == button.tab,
),
);
}),
);
},
);
}
}

View file

@ -0,0 +1,429 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:matrix/matrix.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pangea/chat_settings/pages/room_details_buttons.dart';
import 'package:fluffychat/pangea/chat_settings/pages/room_participants_widget.dart';
import 'package:fluffychat/pangea/chat_settings/pages/space_details_button_row.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_page.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_settings/course_settings.dart';
import 'package:fluffychat/pangea/courses/course_plan_builder.dart';
import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.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';
enum SpaceSettingsTabs {
chat,
course,
participants,
analytics,
more,
}
class SpaceDetailsContent extends StatefulWidget {
final ChatDetailsController controller;
final Room room;
const SpaceDetailsContent(this.controller, this.room, {super.key});
@override
State<SpaceDetailsContent> createState() => SpaceDetailsContentState();
}
class SpaceDetailsContentState extends State<SpaceDetailsContent> {
SpaceSettingsTabs? _selectedTab;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(
() => _selectedTab = FluffyThemes.isColumnMode(context)
? SpaceSettingsTabs.course
: SpaceSettingsTabs.chat,
);
});
}
@override
void didUpdateWidget(covariant SpaceDetailsContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.room.id != widget.room.id) {
setState(() {
_selectedTab = FluffyThemes.isColumnMode(context)
? SpaceSettingsTabs.course
: SpaceSettingsTabs.chat;
});
}
}
void setSelectedTab(SpaceSettingsTabs tab) {
setState(() {
_selectedTab = tab;
});
}
List<ButtonDetails> get _buttons {
final L10n l10n = L10n.of(context);
return [
ButtonDetails(
title: l10n.chats,
icon: const Icon(Icons.chat_bubble_outline, size: 30.0),
onPressed: () => setSelectedTab(SpaceSettingsTabs.chat),
visible: !FluffyThemes.isColumnMode(context),
tab: SpaceSettingsTabs.chat,
),
ButtonDetails(
title: l10n.coursePlan,
icon: const Icon(Icons.map_outlined, size: 30.0),
onPressed: () => setSelectedTab(SpaceSettingsTabs.course),
tab: SpaceSettingsTabs.course,
),
ButtonDetails(
title: l10n.participants,
icon: const Icon(Icons.group_outlined, size: 30.0),
onPressed: () => setSelectedTab(SpaceSettingsTabs.participants),
tab: SpaceSettingsTabs.participants,
),
ButtonDetails(
title: l10n.stats,
icon: const Icon(Symbols.bar_chart_4_bars, size: 30.0),
onPressed: () => setSelectedTab(SpaceSettingsTabs.analytics),
enabled: widget.room.isRoomAdmin,
tab: SpaceSettingsTabs.analytics,
),
ButtonDetails(
title: l10n.invite,
description: l10n.inviteDesc,
icon: const Icon(Icons.person_add_outlined, size: 30.0),
onPressed: () {
String filter = 'knocking';
if (widget.room.getParticipants([Membership.knock]).isEmpty) {
filter = widget.room.pangeaSpaceParents.isNotEmpty
? 'space'
: 'contacts';
}
context.go('/rooms/${widget.room.id}/details/invite?filter=$filter');
},
enabled: widget.room.canInvite && !widget.room.isDirectChat,
showInMainView: false,
),
ButtonDetails(
title: l10n.editCourse,
description: l10n.editCourseDesc,
icon: const Icon(Icons.edit_outlined, size: 30.0),
onPressed: () {},
visible: false,
enabled: widget.room.canChangeStateEvent(PangeaEventTypes.coursePlan),
showInMainView: false,
),
ButtonDetails(
title: l10n.permissions,
description: l10n.permissionsDesc,
icon: const Icon(Icons.edit_attributes_outlined, size: 30.0),
onPressed: () =>
context.go('/rooms/${widget.room.id}/details/permissions'),
enabled: widget.room.isRoomAdmin && !widget.room.isDirectChat,
showInMainView: false,
),
ButtonDetails(
title: l10n.access,
description: l10n.accessDesc,
icon: const Icon(Icons.shield_outlined, size: 30.0),
onPressed: () => context.go('/rooms/${widget.room.id}/details/access'),
enabled: widget.room.isRoomAdmin && widget.room.spaceParents.isEmpty,
showInMainView: false,
),
ButtonDetails(
title: l10n.createGroupChat,
description: l10n.createGroupChatDesc,
icon: const Icon(Symbols.chat_add_on, size: 30.0),
onPressed: widget.controller.addGroupChat,
enabled: widget.room.isRoomAdmin &&
widget.room.canChangeStateEvent(
EventTypes.SpaceChild,
),
showInMainView: false,
),
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: L10n.of(context).leaveSpaceDescription,
isDestructive: true,
);
if (confirmed != OkCancelResult.ok) return;
final resp = await showFutureLoadingDialog(
context: context,
future: widget.room.leaveSpace,
);
if (!resp.isError) {
context.go("/rooms");
}
},
enabled: widget.room.membership == Membership.join,
showInMainView: false,
),
ButtonDetails(
title: l10n.delete,
description: l10n.deleteDesc,
icon: Icon(
Icons.delete_outline,
size: 30.0,
color: Theme.of(context).colorScheme.error,
),
onPressed: () async {
final resp = await showDialog<bool?>(
context: context,
builder: (_) => DeleteSpaceDialog(space: widget.room),
);
if (resp == true) {
context.go("/rooms");
}
},
enabled: widget.room.isRoomAdmin && !widget.room.isDirectChat,
showInMainView: false,
desctructive: true,
),
];
}
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
final displayname = widget.room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
return CoursePlanBuilder(
courseId: widget.room.coursePlan?.uuid,
builder: (context, courseController) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: isColumnMode
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (isColumnMode) ...[
Avatar(
mxContent: widget.room.avatar,
name: displayname,
userId: widget.room.directChatMatrixID,
size: Avatar.defaultSize * 2.5,
borderRadius: widget.room.isSpace
? BorderRadius.circular(24.0)
: null,
),
const SizedBox(width: 16.0),
],
Flexible(
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isColumnMode ? 32.0 : 12.0,
),
),
if (isColumnMode && courseController.course != null)
CourseInfoChips(
courseController.course!,
fontSize: 12.0,
iconSize: 12.0,
),
],
),
),
if (widget.room.classCode != null)
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: PopupMenuButton(
child: const Icon(Symbols.upload),
onSelected: (value) async {
final spaceCode = widget.room.classCode!;
String toCopy = spaceCode;
if (value == 0) {
final String initialUrl = kIsWeb
? html.window.origin!
: Environment.frontendURL;
toCopy =
"$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${widget.room.classCode}";
}
await Clipboard.setData(ClipboardData(text: toCopy));
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).copiedToClipboard,
),
),
);
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<int>>[
PopupMenuItem<int>(
value: 0,
child: ListTile(
title: Text(L10n.of(context).shareSpaceLink),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<int>(
value: 1,
child: ListTile(
title: Text(
L10n.of(context)
.shareInviteCode(widget.room.classCode!),
),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
),
],
),
SizedBox(height: isColumnMode ? 24.0 : 12.0),
SpaceDetailsButtonRow(
controller: widget.controller,
room: widget.room,
selectedTab: _selectedTab,
onTabSelected: setSelectedTab,
buttons: _buttons,
),
SizedBox(height: isColumnMode ? 30.0 : 14.0),
Expanded(
child: Builder(
builder: (context) {
switch (_selectedTab) {
case SpaceSettingsTabs.chat:
return CourseChats(
widget.room.id,
activeChat: null,
client: widget.room.client,
);
case SpaceSettingsTabs.course:
return SingleChildScrollView(
child: CourseSettings(
courseController,
room: widget.room,
),
);
case SpaceSettingsTabs.participants:
return SingleChildScrollView(
child: RoomParticipantsSection(room: widget.room),
);
case SpaceSettingsTabs.analytics:
return SingleChildScrollView(
child: Center(
child: SpaceAnalytics(roomId: widget.room.id),
),
);
case SpaceSettingsTabs.more:
final buttons = _buttons
.where(
(b) => !b.showInMainView && b.visible,
)
.toList();
return SingleChildScrollView(
child: Column(
children: [
if (courseController.course != null) ...[
Text(
courseController.course!.description,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
SizedBox(height: isColumnMode ? 30.0 : 14.0),
],
Column(
spacing: 10.0,
mainAxisSize: MainAxisSize.min,
children: buttons.map((b) {
return Opacity(
opacity: b.enabled ? 1.0 : 0.5,
child: ListTile(
title: Text(
b.title,
style: TextStyle(
fontSize: 12.0,
color: b.desctructive
? Theme.of(context)
.colorScheme
.error
: null,
),
),
subtitle: b.description != null
? Text(
b.description!,
style: TextStyle(
fontSize: 8.0,
color: b.desctructive
? Theme.of(context)
.colorScheme
.error
: null,
),
)
: null,
leading: b.icon,
onTap: b.enabled
? () {
b.onPressed?.call();
}
: null,
),
);
}).toList(),
),
],
),
);
case null:
return const SizedBox();
}
},
),
),
],
);
},
);
}
}

View file

@ -166,6 +166,7 @@ class ModelKey {
static const String activityPlanBookmarkId = "activity_id";
static const String activityPlanEndAt = "end_at";
static const String activityPlanDuration = "duration";
static const String activityPlanTopicId = "topic_id";
static const String activityRequestTopic = "topic";
static const String activityRequestMode = "mode";

View file

@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart';
import 'package:fluffychat/pangea/find_your_people/find_your_people_constants.dart';
import 'package:fluffychat/widgets/navigation_rail.dart';
class PangeaSideView extends StatelessWidget {
final String? path;
const PangeaSideView({
super.key,
required this.path,
});
String get _asset {
const defaultAsset = FindYourPeopleConstants.sideBearFileName;
if (path == null || path!.isEmpty) return defaultAsset;
if (path!.contains('analytics')) {
return AnalyticsPageConstants.dinoBotFileName;
}
return defaultAsset;
}
@override
Widget build(BuildContext context) {
return Row(
children: [
if (FluffyThemes.isColumnMode(context) ||
AppConfig.displayNavigationRail) ...[
SpacesNavigationRail(
activeSpaceId: null,
onGoToChats: () => context.go('/rooms'),
onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'),
),
Container(
color: Colors.transparent,
width: 1,
),
],
Expanded(
child: Center(
child: SizedBox(
width: 250.0,
child: CachedNetworkImage(
imageUrl: "${AppConfig.assetsBaseURL}/$_asset",
errorWidget: (context, url, error) => const SizedBox(),
placeholder: (context, url) => const Center(
child: CircularProgressIndicator.adaptive(),
),
),
),
),
),
],
);
}
}

View file

@ -0,0 +1,851 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_view.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/extensions/join_rule_extension.dart';
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/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.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/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class CourseChats extends StatefulWidget {
final Client client;
final String roomId;
final String? activeChat;
const CourseChats(
this.roomId, {
super.key,
required this.activeChat,
required this.client,
});
@override
State<CourseChats> createState() => CourseChatsController();
}
class CourseChatsController extends State<CourseChats> {
String get roomId => widget.roomId;
Room? get room => widget.client.getRoomById(widget.roomId);
List<SpaceRoomsChunk>? discoveredChildren;
StreamSubscription? _roomSubscription;
String? _nextBatch;
bool noMoreRooms = false;
bool isLoading = false;
CoursePlanModel? course;
String? selectedTopicId;
@override
void initState() {
// load full participant list into memory to ensure widgets
// that rely on full participants list work as expected
final room = widget.client.getRoomById(widget.roomId);
room?.requestParticipants().then((_) {
if (mounted) setState(() {});
});
loadHierarchy(reload: true);
// Listen for changes to the activeSpace's hierarchy,
// and reload the hierarchy when they come through
_roomSubscription ??= widget.client.onSync.stream
.where(_hasHierarchyUpdate)
.listen((update) => loadHierarchy(reload: true));
super.initState();
}
@override
void didUpdateWidget(covariant CourseChats oldWidget) {
// initState doesn't re-run when navigating between spaces
// via the navigation rail, so this accounts for that
super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) {
discoveredChildren = null;
_nextBatch = null;
noMoreRooms = false;
loadHierarchy(reload: true);
}
}
@override
void dispose() {
_roomSubscription?.cancel();
super.dispose();
}
void setCourse(CoursePlanModel? course) {
setState(() {
this.course = course;
});
}
void setSelectedTopicId(String topicID) {
setState(() {
selectedTopicId = topicID;
});
}
int get _selectedTopicIndex =>
course?.topics.indexWhere((t) => t.uuid == selectedTopicId) ?? -1;
bool get canMoveLeft => _selectedTopicIndex > 0;
bool get canMoveRight {
if (course == null) return false;
final endIndex =
room?.ownCurrentTopicIndex(course!) ?? (course!.topics.length - 1);
return _selectedTopicIndex < endIndex;
}
void moveLeft() {
if (canMoveLeft) {
setSelectedTopicId(course!.topics[_selectedTopicIndex - 1].uuid);
}
}
void moveRight() {
if (canMoveRight) {
setSelectedTopicId(course!.topics[_selectedTopicIndex + 1].uuid);
}
}
Topic? get selectedTopic => course?.topics.firstWhereOrNull(
(topic) => topic.uuid == selectedTopicId,
);
Future<void> _joinDefaultChats() async {
if (discoveredChildren == null) return;
final found = List<SpaceRoomsChunk>.from(discoveredChildren!);
final List<Future> joinFutures = [];
for (final chunk in found) {
if (chunk.canonicalAlias == null) continue;
final alias = chunk.canonicalAlias!;
final isDefaultChat = (alias.localpart ?? '')
.startsWith(SpaceConstants.announcementsChatAlias) ||
(alias.localpart ?? '')
.startsWith(SpaceConstants.introductionChatAlias);
if (!isDefaultChat) continue;
joinFutures.add(
widget.client.joinRoom(alias).then((_) {
discoveredChildren?.remove(chunk);
}).catchError((e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
'alias': alias,
'spaceId': widget.roomId,
},
);
return null;
}),
);
}
if (joinFutures.isNotEmpty) {
await Future.wait(joinFutures);
}
}
Future<void> loadHierarchy({reload = false}) async {
final room = widget.client.getRoomById(widget.roomId);
if (room == null) return;
if (mounted) setState(() => isLoading = true);
try {
await _loadHierarchy(activeSpace: room, reload: reload);
await _joinDefaultChats();
} catch (e, s) {
Logs().w('Unable to load hierarchy', e, s);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toLocalizedString(context))),
);
}
} finally {
if (mounted) {
setState(() => isLoading = false);
}
}
}
/// Internal logic of loadHierarchy. It will load the hierarchy of
/// the active space id (or specified spaceId).
/// If [reload] is true, it will reload the entire hierarchy (used when room
/// is added/removed from the space)
/// If [reload] is false, it will load the next set of rooms
Future<void> _loadHierarchy({
required Room activeSpace,
bool reload = false,
}) async {
// Load all of the space's state events. Space Child events
// are used to filtering out unsuggested, unjoined rooms.
await activeSpace.postLoad();
// The current number of rooms loaded for this space that are visible in the UI
final int prevLength = !reload ? (discoveredChildren?.length ?? 0) : 0;
// Failsafe to prevent too many calls to the server in a row
int callsToServer = 0;
List<SpaceRoomsChunk>? currentHierarchy =
discoveredChildren == null || reload
? null
: List.from(discoveredChildren!);
String? currentNextBatch = reload ? null : _nextBatch;
// Makes repeated calls to the server until 10 new visible rooms have
// been loaded, or there are no rooms left to load. Using a loop here,
// rather than one single call to the endpoint, because some spaces have
// so many invisible rooms (analytics rooms) that it might look like
// pressing the 'load more' button does nothing (Because the only rooms
// coming through from those calls are analytics rooms).
while (callsToServer < 5) {
// if this space has been loaded and there are no more rooms to load, break
if (currentHierarchy != null && currentNextBatch == null) {
break;
}
// if this space has been loaded and 10 new rooms have been loaded, break
final int currentLength = currentHierarchy?.length ?? 0;
if (currentLength - prevLength >= 10) {
break;
}
// make the call to the server
final response = await widget.client.getSpaceHierarchy(
widget.roomId,
maxDepth: 1,
from: currentNextBatch,
limit: 100,
);
callsToServer++;
if (response.nextBatch == null) {
noMoreRooms = true;
}
// if rooms have earlier been loaded for this space, add those
// previously loaded rooms to the front of the response list
response.rooms.insertAll(
0,
currentHierarchy ?? [],
);
// finally, set the response to the last response for this space
// and set the current next batch token
currentHierarchy = _filterHierarchyResponse(activeSpace, response.rooms);
currentNextBatch = response.nextBatch;
}
discoveredChildren = currentHierarchy;
discoveredChildren?.sort(_sortSpaceChildren);
_nextBatch = currentNextBatch;
}
void onChatTap(Room room) async {
if (room.membership == Membership.invite) {
final theme = Theme.of(context);
final inviteEvent = room.getState(
EventTypes.RoomMember,
room.client.userID!,
);
final matrixLocals = MatrixLocals(L10n.of(context));
final action = await showAdaptiveDialog<InviteAction>(
barrierDismissible: true,
context: context,
builder: (context) => AlertDialog.adaptive(
title: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: Center(
child: Text(
room.getLocalizedDisplayname(matrixLocals),
textAlign: TextAlign.center,
),
),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256),
child: Text(
inviteEvent == null
? L10n.of(context).inviteForMe
: inviteEvent.content.tryGet<String>('reason') ??
L10n.of(context).youInvitedBy(
room
.unsafeGetUserFromMemoryOrFallback(
inviteEvent.senderId,
)
.calcDisplayname(i18n: matrixLocals),
),
textAlign: TextAlign.center,
),
),
actions: [
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop(InviteAction.accept),
bigButtons: true,
child: Text(L10n.of(context).accept),
),
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop(InviteAction.decline),
bigButtons: true,
child: Text(
L10n.of(context).decline,
style: TextStyle(color: theme.colorScheme.error),
),
),
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop(InviteAction.block),
bigButtons: true,
child: Text(
L10n.of(context).block,
style: TextStyle(color: theme.colorScheme.error),
),
),
],
),
);
switch (action) {
case null:
return;
case InviteAction.accept:
break;
case InviteAction.decline:
await showFutureLoadingDialog(
context: context,
future: () => room.leave(),
);
return;
case InviteAction.block:
final userId = inviteEvent?.senderId;
context.go('/rooms/settings/security/ignorelist', extra: userId);
return;
}
if (!mounted) return;
final joinResult = await showFutureLoadingDialog(
context: context,
future: () async {
final waitForRoom = room.client.waitForRoomInSync(
room.id,
join: true,
);
await room.join();
await waitForRoom;
},
exceptionContext: ExceptionContext.joinRoom,
);
if (joinResult.error != null) return;
}
if (room.membership == Membership.ban) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).youHaveBeenBannedFromThisChat),
),
);
return;
}
if (room.membership == Membership.leave) {
context.go('/rooms/archive/${room.id}');
return;
}
if (room.isSpace) {
context.go("/rooms/spaces/${room.id}/details");
return;
}
context.go('/rooms/${room.id}');
}
void joinChildRoom(SpaceRoomsChunk item) async {
final space = widget.client.getRoomById(widget.roomId);
final joined = await PublicRoomBottomSheet.show(
context: context,
chunk: item,
via: space?.spaceChildren
.firstWhereOrNull(
(child) => child.roomId == item.roomId,
)
?.via,
);
if (mounted && joined == true) {
setState(() {
discoveredChildren?.remove(item);
});
}
}
void chatContextAction(
Room room,
BuildContext posContext, [
Room? space,
]) async {
final overlay =
Overlay.of(posContext).context.findRenderObject() as RenderBox;
final button = posContext.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(const Offset(0, -65), ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
ancestor: overlay,
),
),
Offset.zero & overlay.size,
);
final displayname =
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)));
final spacesWithPowerLevels = room.client.rooms
.where(
(space) =>
space.isSpace &&
space.canChangeStateEvent(EventTypes.SpaceChild) &&
!space.spaceChildren.any((c) => c.roomId == room.id),
)
.toList();
final action = await showMenu<ChatContextAction>(
context: posContext,
position: position,
items: [
PopupMenuItem(
value: ChatContextAction.open,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 12.0,
children: [
Avatar(
mxContent: room.avatar,
name: displayname,
userId: room.directChatMatrixID,
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 128),
child: Text(
displayname,
style:
TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const PopupMenuDivider(),
if (space != null)
PopupMenuItem(
value: ChatContextAction.goToSpace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
mxContent: space.avatar,
size: Avatar.defaultSize / 2,
name: space.getLocalizedDisplayname(),
userId: space.directChatMatrixID,
),
const SizedBox(width: 12),
Expanded(
child: Text(
L10n.of(context).goToSpace(space.getLocalizedDisplayname()),
),
),
],
),
),
if (room.membership == Membership.join) ...[
PopupMenuItem(
value: ChatContextAction.mute,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.pushRuleState == PushRuleState.notify
? Icons.notifications_on_outlined
: Icons.notifications_off_outlined,
),
const SizedBox(width: 12),
Text(
room.pushRuleState == PushRuleState.notify
? L10n.of(context).notificationsOn
: L10n.of(context).notificationsOff,
),
],
),
),
PopupMenuItem(
value: ChatContextAction.markUnread,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.markedUnread
? Icons.mark_as_unread
: Icons.mark_as_unread_outlined,
),
const SizedBox(width: 12),
Text(
room.markedUnread
? L10n.of(context).markAsRead
: L10n.of(context).markAsUnread,
),
],
),
),
PopupMenuItem(
value: ChatContextAction.favorite,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
),
const SizedBox(width: 12),
Text(
room.isFavourite
? L10n.of(context).unpin
: L10n.of(context).pin,
),
],
),
),
if (spacesWithPowerLevels.isNotEmpty &&
room.canChangeStateEvent(EventTypes.SpaceParent))
PopupMenuItem(
value: ChatContextAction.addToSpace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.groups_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).addToSpace),
],
),
),
// if the room has a parent for which the user has a high enough power level
// to set parent's space child events, show option to remove the room from the space
if (room.spaceParents.isNotEmpty &&
room.canChangeStateEvent(EventTypes.SpaceParent) &&
room.pangeaSpaceParents.any(
(r) =>
r.canChangeStateEvent(EventTypes.SpaceChild) &&
r.id == widget.roomId,
))
PopupMenuItem(
value: ChatContextAction.removeFromSpace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete_sweep_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).removeFromSpace),
],
),
),
],
PopupMenuItem(
value: ChatContextAction.leave,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.logout_outlined,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Text(
room.membership == Membership.invite
? L10n.of(context).delete
: L10n.of(context).leave,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
if (room.isRoomAdmin && !room.isDirectChat)
PopupMenuItem(
value: ChatContextAction.delete,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.delete_outlined,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Text(
L10n.of(context).delete,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
],
);
if (action == null) return;
if (!mounted) return;
switch (action) {
case ChatContextAction.open:
onChatTap(room);
return;
case ChatContextAction.goToSpace:
context.go("/rooms/spaces/${space!.id}/details");
return;
case ChatContextAction.favorite:
await showFutureLoadingDialog(
context: context,
future: () => room.setFavourite(!room.isFavourite),
);
return;
case ChatContextAction.markUnread:
await showFutureLoadingDialog(
context: context,
future: () => room.markUnread(!room.markedUnread),
);
return;
case ChatContextAction.mute:
await showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentionsOnly
: PushRuleState.notify,
),
);
return;
case ChatContextAction.leave:
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
message: room.isSpace
? L10n.of(context).leaveSpaceDescription
: L10n.of(context).leaveRoomDescription,
okLabel: L10n.of(context).leave,
cancelLabel: L10n.of(context).cancel,
isDestructive: true,
);
if (confirmed != OkCancelResult.ok) return;
if (!mounted) return;
final resp = await showFutureLoadingDialog(
context: context,
future: room.isSpace ? room.leaveSpace : room.leave,
);
if (mounted && !resp.isError) {
context.go("/rooms");
}
return;
case ChatContextAction.addToSpace:
final space = await showModalActionPopup(
context: context,
title: L10n.of(context).space,
actions: spacesWithPowerLevels
.map(
(space) => AdaptiveModalAction(
value: space,
label: space
.getLocalizedDisplayname(MatrixLocals(L10n.of(context))),
),
)
.toList(),
);
if (space == null) return;
if (room.isSpace) {
final resp = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).addSubspaceWarning,
);
if (resp == OkCancelResult.cancel) return;
}
await showFutureLoadingDialog(
context: context,
future: () => space.addToSpace(room.id),
);
try {
await space.setSpaceChildAccess();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).accessSettingsWarning),
duration: const Duration(seconds: 10),
),
);
}
return;
case ChatContextAction.removeFromSpace:
await showFutureLoadingDialog(
context: context,
future: () async {
final activeSpace = room.client.getRoomById(widget.roomId);
if (activeSpace == null) return;
await activeSpace.removeSpaceChild(room.id);
},
);
try {
await room.resetSpaceChildAccess();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).accessSettingsWarning),
duration: const Duration(seconds: 10),
),
);
}
return;
case ChatContextAction.delete:
if (room.isSpace) {
final resp = await showDialog<bool?>(
context: context,
builder: (_) => DeleteSpaceDialog(space: room),
);
if (resp == true && mounted) {
context.go("/rooms");
}
} 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;
if (!mounted) return;
final resp = await showFutureLoadingDialog(
context: context,
future: room.delete,
);
if (mounted && !resp.isError) {
context.go("/rooms/spaces/${widget.roomId}/details");
}
}
return;
}
}
bool _includeSpaceChild(
Room space,
SpaceRoomsChunk hierarchyMember,
) {
if (!mounted) return false;
final bool isAnalyticsRoom =
hierarchyMember.roomType == PangeaRoomTypes.analytics;
final bool isMember = [Membership.join, Membership.invite].contains(
widget.client.getRoomById(hierarchyMember.roomId)?.membership,
);
final bool isSuggested =
space.spaceChildSuggestionStatus[hierarchyMember.roomId] ?? true;
return !isAnalyticsRoom && (isMember || isSuggested);
}
List<SpaceRoomsChunk> _filterHierarchyResponse(
Room space,
List<SpaceRoomsChunk> hierarchyResponse,
) {
final List<SpaceRoomsChunk> filteredChildren = [];
for (final child in hierarchyResponse) {
if (child.roomId == widget.roomId) {
continue;
}
final room = space.client.getRoomById(child.roomId);
if (room != null && room.membership != Membership.leave) {
// If the room is already joined or invited, skip it
continue;
}
final isDuplicate = filteredChildren.any(
(filtered) => filtered.roomId == child.roomId,
);
if (isDuplicate) continue;
if (_includeSpaceChild(space, child)) {
filteredChildren.add(child);
}
}
return filteredChildren;
}
/// Used to filter out sync updates with hierarchy updates for the active
/// space so that the view can be auto-reloaded in the room subscription
bool _hasHierarchyUpdate(SyncUpdate update) {
final joinTimeline = update.rooms?.join?[widget.roomId]?.timeline;
final leaveTimeline = update.rooms?.leave?[widget.roomId]?.timeline;
if (joinTimeline == null && leaveTimeline == null) return false;
final bool hasJoinUpdate = joinTimeline?.events?.any(
(event) => event.type == EventTypes.SpaceChild,
) ??
false;
final bool hasLeaveUpdate = leaveTimeline?.events?.any(
(event) => event.type == EventTypes.SpaceChild,
) ??
false;
return hasJoinUpdate || hasLeaveUpdate;
}
int _sortSpaceChildren(
SpaceRoomsChunk a,
SpaceRoomsChunk b,
) {
final bool aIsSpace = a.roomType == 'm.space';
final bool bIsSpace = b.roomType == 'm.space';
if (aIsSpace && !bIsSpace) {
return -1;
} else if (!aIsSpace && bIsSpace) {
return 1;
}
return 0;
}
@override
Widget build(BuildContext context) => CourseChatsView(this);
}

View file

@ -0,0 +1,348 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_page.dart';
import 'package:fluffychat/pangea/course_chats/unjoined_chat_list_item.dart';
import 'package:fluffychat/pangea/courses/course_plan_builder.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/stream_extension.dart';
class CourseChatsView extends StatelessWidget {
final CourseChatsController controller;
const CourseChatsView(
this.controller, {
super.key,
});
@override
Widget build(BuildContext context) {
final room = controller.room;
if (room == null) {
return const Center(
child: Icon(
Icons.search_outlined,
size: 80,
),
);
}
return CoursePlanBuilder(
courseId: room.coursePlan?.uuid,
onFound: (course) {
controller.setCourse(course);
final topic = room.ownCurrentTopic(course);
if (topic != null) controller.setSelectedTopicId(topic.uuid);
},
builder: (context, courseController) {
final Topic? topic = controller.selectedTopic;
final List<String> activityIds = topic?.activityIds ?? [];
final childrenIds =
room.spaceChildren.map((c) => c.roomId).whereType<String>().toSet();
final joinedChats = [];
final joinedSessions = [];
final joinedRooms = room.client.rooms
.where((room) => childrenIds.remove(room.id))
.where((room) => !room.isHiddenRoom)
.toList();
for (final joinedRoom in joinedRooms) {
if (joinedRoom.isActivitySession) {
if (activityIds.contains(joinedRoom.activityPlan?.bookmarkId)) {
joinedSessions.add(joinedRoom);
}
} else {
joinedChats.add(joinedRoom);
}
}
final discoveredGroupChats = [];
final discoveredSessions = [];
final discoveredChildren =
controller.discoveredChildren ?? <SpaceRoomsChunk>[];
for (final child in discoveredChildren) {
if (child.roomType?.startsWith(PangeaRoomTypes.activitySession) ==
true) {
if (activityIds.contains(child.roomType!.split(":").last)) {
discoveredSessions.add(child);
}
} else {
discoveredGroupChats.add(child);
}
}
final isColumnMode = FluffyThemes.isColumnMode(context);
return StreamBuilder(
stream: room.client.onSync.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1)),
builder: (context, snapshot) {
return Padding(
padding: isColumnMode
? const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 8.0,
)
: const EdgeInsets.all(0.0),
child: ListView.builder(
shrinkWrap: true,
itemCount: joinedChats.length +
joinedSessions.length +
discoveredGroupChats.length +
discoveredSessions.length +
5,
itemBuilder: (context, i) {
// courses chats title
if (i == 0) {
if (isColumnMode) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 24.0),
const Icon(
Icons.chat_bubble_outline,
size: 30.0,
),
Text(
L10n.of(context).courseChats,
style: const TextStyle(fontSize: 12.0),
),
const SizedBox(height: 14.0),
],
),
);
}
return const SizedBox();
}
i--;
// joined group chats
if (i < joinedChats.length) {
final joinedRoom = joinedChats[i];
return ChatListItem(
joinedRoom,
onTap: () => controller.onChatTap(joinedRoom),
onLongPress: (context) => controller.chatContextAction(
joinedRoom,
context,
),
activeChat: controller.widget.activeChat == joinedRoom.id,
);
}
i -= joinedChats.length;
// unjoined group chats
if (i < discoveredGroupChats.length) {
return UnjoinedChatListItem(
chunk: discoveredGroupChats[i],
onTap: () =>
controller.joinChildRoom(discoveredGroupChats[i]),
);
}
i -= discoveredGroupChats.length;
if (i == 0) {
if (room.coursePlan == null ||
(courseController.course == null &&
!courseController.loading)) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 16.0,
),
child: courseController.loading
? LinearProgressIndicator(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
)
: topic != null
? Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
MouseRegion(
cursor: controller.canMoveLeft
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: controller.canMoveLeft
? controller.moveLeft
: null,
child: Opacity(
opacity: controller.canMoveLeft
? 1.0
: 0.3,
child: const Icon(
Icons.arrow_left,
size: 24.0,
),
),
),
),
Row(
spacing: 6.0,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.location_on,
size: 24.0,
),
Text(
topic.location,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
MouseRegion(
cursor: controller.canMoveRight
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: controller.canMoveRight
? controller.moveRight
: null,
child: Opacity(
opacity: controller.canMoveRight
? 1.0
: 0.3,
child: const Icon(
Icons.arrow_right,
size: 24.0,
),
),
),
),
],
)
: const SizedBox(),
);
}
i--;
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0,
left: 24.0,
right: 24.0,
),
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.event_note_outlined,
size: 14.0,
),
Text(
L10n.of(context).myActivitySessions,
style: const TextStyle(fontSize: 12.0),
),
],
),
);
}
i--;
if (i == 0) {
return joinedSessions.isEmpty && discoveredSessions.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
L10n.of(context).noSessionsFound,
style: const TextStyle(
fontSize: 12.0,
),
),
const Icon(Icons.map_outlined, size: 24.0),
],
),
)
: const SizedBox();
}
i--;
// joined activity sessions
if (i < joinedSessions.length) {
final joinedRoom = joinedSessions[i];
return ChatListItem(
joinedRoom,
onTap: () => controller.onChatTap(joinedRoom),
onLongPress: (context) => controller.chatContextAction(
joinedRoom,
context,
),
activeChat: controller.widget.activeChat == joinedRoom.id,
);
}
i -= joinedSessions.length;
// unjoined activity sessions
if (i < discoveredSessions.length) {
return UnjoinedChatListItem(
chunk: discoveredSessions[i],
onTap: () => controller.joinChildRoom(
discoveredSessions[i],
),
);
}
i -= discoveredSessions.length;
if (controller.noMoreRooms) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 2.0,
),
child: TextButton(
onPressed: controller.isLoading
? null
: controller.loadHierarchy,
child: controller.isLoading
? LinearProgressIndicator(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
)
: Text(L10n.of(context).loadMore),
),
);
},
),
);
},
);
},
);
}
}

View file

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
class UnjoinedChatListItem extends StatelessWidget {
final SpaceRoomsChunk chunk;
final VoidCallback onTap;
const UnjoinedChatListItem({
super.key,
required this.chunk,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final displayname =
chunk.name ?? chunk.canonicalAlias ?? L10n.of(context).emptyChat;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
child: Material(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListTile(
visualDensity: const VisualDensity(vertical: -0.5),
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
onTap: onTap,
leading: Avatar(
mxContent: chunk.avatarUrl,
name: displayname,
userId: Matrix.of(context)
.client
.getRoomById(chunk.roomId)
?.directChatMatrixID,
borderRadius: chunk.roomType == 'm.space'
? BorderRadius.circular(
AppConfig.borderRadius / 2,
)
: null,
),
title: Row(
children: [
Expanded(
child: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
chunk.numJoinedMembers.toString(),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).textTheme.bodyMedium!.color,
),
),
const SizedBox(width: 4),
const Icon(
Icons.people_outlined,
size: 14,
),
],
),
subtitle: Text(
chunk.topic ??
L10n.of(context).countParticipants(
chunk.numJoinedMembers,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
}
}

View file

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
class CourseInfoChip extends StatelessWidget {
final IconData icon;
final String text;
final double fontSize;
final double iconSize;
final EdgeInsets? padding;
const CourseInfoChip({
super.key,
required this.icon,
required this.text,
required this.fontSize,
required this.iconSize,
this.padding,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? EdgeInsets.zero,
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
),
Text(
text,
style: TextStyle(
fontSize: fontSize,
),
),
],
),
);
}
}
class CourseInfoChips extends StatelessWidget {
final CoursePlanModel course;
final double fontSize;
final double iconSize;
final EdgeInsets? padding;
const CourseInfoChips(
this.course, {
super.key,
required this.fontSize,
required this.iconSize,
this.padding,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8.0,
runSpacing: 8.0,
alignment: WrapAlignment.center,
children: [
CourseInfoChip(
icon: Icons.language,
text:
"${course.baseLanguageDisplay}${course.targetLanguageDisplay}",
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
),
CourseInfoChip(
icon: Icons.school,
text: course.cefrLevel.string,
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
),
CourseInfoChip(
icon: Icons.location_on,
text: L10n.of(context).numModules(course.topics.length),
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
),
CourseInfoChip(
icon: Icons.event_note_outlined,
text: L10n.of(context).numActivityPlans(course.activities),
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
),
],
);
}
}

View file

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart';
class CoursePlanFilter<T> extends StatefulWidget {
final T? value;
final List<T> items;
final void Function(T?) onChanged;
final String Function(T?) displayname;
final bool enableSearch;
final double fontSize;
final double iconSize;
const CoursePlanFilter({
super.key,
required this.value,
required this.items,
required this.onChanged,
required this.displayname,
required this.fontSize,
required this.iconSize,
this.enableSearch = false,
});
@override
State<CoursePlanFilter<T>> createState() => CoursePlanFilterState<T>();
}
class CoursePlanFilterState<T> extends State<CoursePlanFilter<T>> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DropdownButtonHideUnderline(
child: DropdownButton2<T>(
customButton: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(12.0),
),
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 2.0,
),
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.displayname(widget.value),
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: widget.fontSize,
),
),
Icon(
Icons.arrow_drop_down,
color: theme.colorScheme.onPrimary,
size: widget.iconSize,
),
],
),
),
value: widget.value,
items: [null, ...widget.items]
.map(
(item) => DropdownMenuItem(
value: item,
child: DropdownTextButton(
text: item == null ? "" : widget.displayname(item),
isSelected: item == widget.value,
),
),
)
.toList(),
onChanged: widget.onChanged,
buttonStyleData: ButtonStyleData(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
),
),
dropdownStyleData: const DropdownStyleData(
width: 250,
),
dropdownSearchData: widget.enableSearch
? DropdownSearchData(
searchController: _searchController,
searchInnerWidgetHeight: 50,
searchInnerWidget: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: TextField(
autofocus: true,
controller: _searchController,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
),
),
),
searchMatchFn: (item, searchValue) {
final displayName =
widget.displayname(item.value).toLowerCase();
final search = searchValue.toLowerCase();
return displayName.startsWith(search);
},
)
: null,
onMenuStateChange: (isOpen) {
if (!isOpen) _searchController.clear();
},
),
);
}
}

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
class CoursePlanTile extends StatelessWidget {
final CoursePlanModel course;
final VoidCallback onTap;
final double titleFontSize;
final double chipFontSize;
final double chipIconSize;
const CoursePlanTile({
super.key,
required this.course,
required this.onTap,
required this.titleFontSize,
required this.chipFontSize,
required this.chipIconSize,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return HoverBuilder(
builder: (context, hovered) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color:
hovered ? theme.colorScheme.onSurface.withAlpha(10) : null,
),
child: Row(
spacing: 4.0,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: course.imageUrl != null
? CachedNetworkImage(
width: 40.0,
height: 40.0,
fit: BoxFit.cover,
imageUrl: course.imageUrl!,
placeholder: (context, url) {
return const Center(
child: CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return Container(
width: 40.0,
height: 40.0,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
),
);
},
)
: Container(
width: 40.0,
height: 40.0,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
),
),
),
Flexible(
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
course.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: titleFontSize,
),
),
CourseInfoChips(
course,
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 2.0,
),
fontSize: chipFontSize,
iconSize: chipIconSize,
),
],
),
),
],
),
),
),
);
},
);
}
}

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/course_creation/new_course_view.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/courses/course_repo.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
class NewCourse extends StatefulWidget {
const NewCourse({super.key});
@override
State<NewCourse> createState() => NewCourseController();
}
class NewCourseController extends State<NewCourse> {
bool loading = true;
Object? error;
List<CoursePlanModel> courses = [];
LanguageLevelTypeEnum? languageLevelFilter;
LanguageModel? instructionLanguageFilter;
LanguageModel? targetLanguageFilter;
@override
void initState() {
super.initState();
_loadCourses();
}
CourseFilter get _filter {
return CourseFilter(
targetLanguage: targetLanguageFilter,
languageOfInstructions: instructionLanguageFilter,
cefrLevel: languageLevelFilter,
);
}
void setLanguageLevelFilter(LanguageLevelTypeEnum? level) {
languageLevelFilter = level;
_loadCourses();
}
void setInstructionLanguageFilter(LanguageModel? language) {
instructionLanguageFilter = language;
_loadCourses();
}
void setTargetLanguageFilter(LanguageModel? language) {
targetLanguageFilter = language;
_loadCourses();
}
Future<void> _loadCourses() async {
try {
setState(() => loading = true);
courses = await CourseRepo.search(filter: _filter);
} catch (e) {
error = e;
} finally {
setState(() => loading = false);
}
}
@override
Widget build(BuildContext context) => NewCourseView(this);
}

View file

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/course_creation/course_plan_filter_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_plan_tile_widget.dart';
import 'package:fluffychat/pangea/course_creation/new_course_page.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
class NewCourseView extends StatelessWidget {
final NewCourseController controller;
const NewCourseView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
const double titleFontSize = 16.0;
const double descFontSize = 12.0;
const double iconSize = 12.0;
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).newCourse),
),
body: Padding(
padding: const EdgeInsets.all(12.0),
child: MaxWidthBody(
showBorder: false,
withScrolling: false,
maxWidth: 500.0,
child: Column(
spacing: 12.0,
children: [
Text(
L10n.of(context).newCourseSubtitle,
style: const TextStyle(
fontSize: titleFontSize,
),
),
Padding(
padding: const EdgeInsetsGeometry.symmetric(
vertical: 4.0,
),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
alignment: WrapAlignment.start,
children: [
CoursePlanFilter<LanguageLevelTypeEnum>(
value: controller.languageLevelFilter,
onChanged: controller.setLanguageLevelFilter,
items: LanguageLevelTypeEnum.values,
displayname: (v) =>
v?.string ?? L10n.of(context).cefrLevelLabel,
fontSize: descFontSize,
iconSize: iconSize,
),
CoursePlanFilter<LanguageModel>(
value: controller.instructionLanguageFilter,
onChanged: controller.setInstructionLanguageFilter,
items: MatrixState
.pangeaController.pLanguageStore.baseOptions,
displayname: (v) =>
v?.getDisplayName(context) ??
L10n.of(context).languageOfInstructionsLabel,
enableSearch: true,
fontSize: descFontSize,
iconSize: iconSize,
),
CoursePlanFilter<LanguageModel>(
value: controller.targetLanguageFilter,
onChanged: controller.setTargetLanguageFilter,
items: MatrixState
.pangeaController.pLanguageStore.targetOptions,
displayname: (v) =>
v?.getDisplayName(context) ??
L10n.of(context).targetLanguageLabel,
enableSearch: true,
fontSize: descFontSize,
iconSize: iconSize,
),
],
),
),
],
),
),
Builder(
builder: (context) {
if (controller.error != null) {
return Center(
child: ErrorIndicator(
message: L10n.of(context).failedToLoadCourses,
),
);
}
if (controller.loading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
return Expanded(
child: ListView.builder(
itemCount: controller.courses.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsetsGeometry.fromLTRB(
4.0,
4.0,
4.0,
16.0,
),
child: CoursePlanTile(
course: controller.courses[index],
onTap: () => context.go(
"/rooms/communities/newcourse/${controller.courses[index].uuid}",
),
titleFontSize: titleFontSize,
chipFontSize: descFontSize,
chipIconSize: iconSize,
),
),
),
);
},
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,70 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/course_creation/selected_course_view.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/spaces/utils/client_spaces_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SelectedCourse extends StatefulWidget {
final String courseId;
const SelectedCourse(this.courseId, {super.key});
@override
SelectedCourseController createState() => SelectedCourseController();
}
class SelectedCourseController extends State<SelectedCourse> {
Future<void> launchCourse(CoursePlanModel course) async {
final client = Matrix.of(context).client;
Uint8List? avatar;
Uri? avatarUrl;
if (course.imageUrl != null) {
try {
final Response response = await http.get(Uri.parse(course.imageUrl!));
avatar = response.bodyBytes;
avatarUrl = await client.uploadContent(avatar);
} catch (e) {
debugPrint("Error fetching course image: $e");
}
}
final roomId = await client.createPangeaSpace(
name: course.title,
introChatName: L10n.of(context).introductions,
announcementsChatName: L10n.of(context).announcements,
visibility: sdk.Visibility.private,
joinRules: sdk.JoinRules.knock,
initialState: [
sdk.StateEvent(
type: PangeaEventTypes.coursePlan,
content: {
"uuid": course.uuid,
},
),
],
avatar: avatar,
avatarUrl: avatarUrl,
spaceChild: 0,
);
if (!mounted) return;
final room = client.getRoomById(roomId);
if (room == null) return;
context.go("/rooms/spaces/${room.id}/details");
}
@override
Widget build(BuildContext context) => SelectedCourseView(
courseId: widget.courseId,
launchCourse: launchCourse,
);
}

View file

@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/courses/course_plan_builder.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
class SelectedCourseView extends StatelessWidget {
final String courseId;
final Future<void> Function(CoursePlanModel course) launchCourse;
const SelectedCourseView({
super.key,
required this.courseId,
required this.launchCourse,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const double titleFontSize = 16.0;
const double descFontSize = 12.0;
const double largeIconSize = 24.0;
const double mediumIconSize = 16.0;
const double smallIconSize = 12.0;
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).newCourse),
),
body: CoursePlanBuilder(
courseId: courseId,
onNotFound: () => context.go("/rooms/communities/newcourse"),
builder: (context, controller) {
final course = controller.course;
return MaxWidthBody(
showBorder: false,
withScrolling: false,
maxWidth: 500.0,
child: course == null
? const Center(child: CircularProgressIndicator.adaptive())
: Stack(
alignment: Alignment.bottomCenter,
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: ListView.builder(
itemCount: course.topics.length + 2,
itemBuilder: (context, index) {
if (index == 0) {
return Column(
spacing: 8.0,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: course.imageUrl != null
? CachedNetworkImage(
width: 100.0,
height: 100.0,
fit: BoxFit.cover,
imageUrl: course.imageUrl!,
placeholder: (context, url) {
return const Center(
child:
CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return Container(
width: 100.0,
height: 100.0,
decoration: BoxDecoration(
color: theme
.colorScheme.secondary,
),
);
},
)
: Container(
width: 100.0,
height: 100.0,
decoration: BoxDecoration(
color:
theme.colorScheme.secondary,
),
),
),
Text(
course.title,
style: const TextStyle(
fontSize: titleFontSize,
),
),
Text(
course.description,
style:
const TextStyle(fontSize: descFontSize),
),
CourseInfoChips(
course,
fontSize: descFontSize,
iconSize: smallIconSize,
),
Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 8.0,
),
child: Row(
spacing: 4.0,
children: [
const Icon(
Icons.map,
size: largeIconSize,
),
Text(
L10n.of(context).coursePlan,
style: const TextStyle(
fontSize: titleFontSize,
),
),
],
),
),
],
);
}
index--;
if (index == course.topics.length) {
return const SizedBox(height: 150.0);
}
final topic = course.topics[index];
return Padding(
padding:
const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(80),
child: topic.imageUrl != null
? CachedNetworkImage(
width: 40.0,
height: 40.0,
fit: BoxFit.cover,
imageUrl: topic.imageUrl!,
placeholder: (context, url) {
return const Center(
child:
CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return Container(
width: 40.0,
height: 40.0,
decoration: BoxDecoration(
color: theme
.colorScheme.secondary,
),
);
},
)
: Container(
width: 40.0,
height: 40.0,
decoration: BoxDecoration(
color:
theme.colorScheme.secondary,
),
),
),
Flexible(
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
topic.title,
style: const TextStyle(
fontSize: titleFontSize,
),
),
Text(
topic.description,
style: const TextStyle(
fontSize: descFontSize,
),
),
Padding(
padding: const EdgeInsetsGeometry
.symmetric(
vertical: 2.0,
),
child: Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
CourseInfoChip(
icon: Icons.location_on,
text: topic.location,
fontSize: descFontSize,
iconSize: smallIconSize,
),
CourseInfoChip(
icon: Icons.event_note_outlined,
text: L10n.of(context)
.numActivityPlans(
topic.activities.length,
),
fontSize: descFontSize,
iconSize: smallIconSize,
),
],
),
),
],
),
),
],
),
);
},
),
),
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.dividerColor,
width: 1.0,
),
),
),
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
Row(
spacing: 12.0,
children: [
const Icon(
Icons.edit,
size: mediumIconSize,
),
Flexible(
child: Text(
L10n.of(context).editCourseLater,
style:
const TextStyle(fontSize: descFontSize),
),
),
],
),
Row(
spacing: 12.0,
children: [
const Icon(
Icons.shield,
size: mediumIconSize,
),
Flexible(
child: Text(
L10n.of(context).newCourseAccess,
style:
const TextStyle(fontSize: descFontSize),
),
),
],
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 16.0,
),
),
onPressed: () => showFutureLoadingDialog(
context: context,
future: () => launchCourse(course),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).createCourse,
style: const TextStyle(
fontSize: titleFontSize,
),
),
],
),
),
],
),
),
],
),
);
},
),
);
}
}

View file

@ -0,0 +1,326 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/courses/course_plan_builder.dart';
import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart';
import 'package:fluffychat/widgets/avatar.dart';
class CourseSettings extends StatelessWidget {
final Room room;
final CoursePlanController controller;
const CourseSettings(
this.controller, {
super.key,
required this.room,
});
@override
Widget build(BuildContext context) {
if (controller.loading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null) {
return Center(
child: ErrorIndicator(message: L10n.of(context).failedToLoadCourseInfo),
);
}
if (controller.course == null) {
return Center(child: Text(L10n.of(context).noCourseFound));
}
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final double titleFontSize = isColumnMode ? 32.0 : 12.0;
final double descFontSize = isColumnMode ? 12.0 : 8.0;
final double iconSize = isColumnMode ? 16.0 : 12.0;
final course = controller.course!;
final currentTopicIndex = room.currentTopicIndex(
room.client.userID!,
course,
);
final topicsToUsers = room.topicsToUsers(course);
return Column(
spacing: isColumnMode ? 30.0 : 12.0,
mainAxisSize: MainAxisSize.min,
children: course.topics.mapIndexed((index, topic) {
final unlocked = index <= currentTopicIndex;
final usersInTopic = topicsToUsers[topic.uuid] ?? [];
return AbsorbPointer(
absorbing: !unlocked,
child: Opacity(
opacity: unlocked ? 1.0 : 0.5,
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
LayoutBuilder(
builder: (context, constraints) {
return Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(80),
child: topic.imageUrl != null
? CachedNetworkImage(
width: 54.0,
height: 54.0,
fit: BoxFit.cover,
imageUrl: topic.imageUrl!,
placeholder: (context, url) {
return const Center(
child:
CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return Container(
width: 54.0,
height: 54.0,
decoration: BoxDecoration(
color: theme
.colorScheme.secondary,
),
);
},
)
: Container(
width: 54.0,
height: 54.0,
decoration: BoxDecoration(
color:
theme.colorScheme.secondary,
),
),
),
if (!unlocked)
const Positioned(
bottom: 0,
right: 0,
child: Icon(Icons.lock, size: 24.0),
),
],
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
topic.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: titleFontSize,
),
),
CourseInfoChip(
icon: Icons.location_on,
text: topic.location,
fontSize: descFontSize,
iconSize: iconSize,
),
if (constraints.maxWidth < 700.0)
Padding(
padding:
const EdgeInsetsGeometry.symmetric(
vertical: 4.0,
),
child: TopicParticipantList(
room: room,
users: usersInTopic,
avatarSize:
isColumnMode ? 50.0 : 25.0,
overlap: isColumnMode ? 20.0 : 8.0,
),
),
],
),
),
],
),
),
if (constraints.maxWidth >= 700.0)
TopicParticipantList(
room: room,
users: usersInTopic,
avatarSize: isColumnMode ? 50.0 : 25.0,
overlap: isColumnMode ? 20.0 : 8.0,
),
],
);
},
),
if (unlocked)
SizedBox(
height: 210.0,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: topic.activities.length,
itemBuilder: (context, index) {
final activity = topic.activities[index];
return Padding(
padding: const EdgeInsets.only(right: 24.0),
child: ActivityPlannerBuilder(
initialActivity: activity,
room: room,
builder: (activityController) {
return ActivitySuggestionCard(
controller: activityController,
onPressed: () {
showDialog(
context: context,
builder: (context) {
return ActivitySuggestionDialog(
controller: activityController,
buttonText:
L10n.of(context).launchToSpace,
);
},
);
},
width: 120.0,
height: 200.0,
fontSize: 12.0,
fontSizeSmall: 8.0,
iconSize: 8.0,
);
},
),
);
},
),
),
],
),
),
);
}).toList(),
);
}
}
class TopicParticipantList extends StatelessWidget {
final Room room;
final List<User> users;
final double avatarSize;
final int maxVisible;
final double overlap;
const TopicParticipantList({
super.key,
required this.room,
required this.users,
this.avatarSize = 50.0,
this.maxVisible = 6,
this.overlap = 20.0,
});
@override
Widget build(BuildContext context) {
final maxWidth =
(avatarSize - overlap) * min(users.length, maxVisible) + overlap;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: maxWidth,
height: avatarSize,
child: LoadParticipantsUtil(
space: room,
builder: (participantsLoader) {
final publicProfiles = Map.fromEntries(
users.map(
(u) => MapEntry(
u.id,
participantsLoader.getAnalyticsProfile(u.id)?.level,
),
),
);
users.sort((a, b) {
final aLevel = publicProfiles[a.id];
final bLevel = publicProfiles[b.id];
if (aLevel != null && bLevel != null) {
return bLevel.compareTo(aLevel);
}
return 0;
});
return Stack(
children: users.take(maxVisible).mapIndexed((index, user) {
final level = publicProfiles[user.id];
final LinearGradient? gradient =
level != null ? index.leaderboardGradient : null;
return Positioned(
left: index * (avatarSize - overlap),
child: Stack(
alignment: Alignment.center,
children: [
if (gradient != null)
CircleAvatar(
radius: avatarSize / 2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: gradient,
),
),
)
else
SizedBox(
height: avatarSize,
width: avatarSize,
),
Center(
child: Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
size: avatarSize - 6.0,
userId: user.id,
),
),
],
),
);
}).toList(),
);
},
),
),
if (users.length > maxVisible)
Text(
L10n.of(context).additionalParticipants(users.length - maxVisible),
style: const TextStyle(
fontSize: 12.0,
),
),
],
);
}
}

View file

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/courses/course_repo.dart';
class CoursePlanBuilder extends StatefulWidget {
final String? courseId;
final VoidCallback? onNotFound;
final Function(CoursePlanModel course)? onFound;
final Widget Function(
BuildContext context,
CoursePlanController controller,
) builder;
const CoursePlanBuilder({
super.key,
required this.courseId,
required this.builder,
this.onNotFound,
this.onFound,
});
@override
State<CoursePlanBuilder> createState() => CoursePlanController();
}
class CoursePlanController extends State<CoursePlanBuilder> {
bool loading = true;
Object? error;
CoursePlanModel? course;
@override
void initState() {
super.initState();
_loadCourse();
}
@override
void didUpdateWidget(covariant CoursePlanBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.courseId != widget.courseId) {
_loadCourse();
}
}
Future<void> _loadCourse() async {
if (widget.courseId == null) {
setState(() {
loading = false;
error = null;
course = null;
});
return;
}
try {
setState(() {
loading = true;
error = null;
});
course = await CourseRepo.get(widget.courseId!);
course == null
? widget.onNotFound?.call()
: widget.onFound?.call(course!);
} catch (e) {
error = e;
} finally {
setState(() {
loading = false;
});
}
}
@override
Widget build(BuildContext context) => widget.builder(context, this);
}

View file

@ -0,0 +1,17 @@
class CoursePlanEvent {
final String uuid;
CoursePlanEvent({required this.uuid});
Map<String, dynamic> toJson() {
return {
'uuid': uuid,
};
}
factory CoursePlanEvent.fromJson(Map<String, dynamic> json) {
return CoursePlanEvent(
uuid: json['uuid'] as String,
);
}
}

View file

@ -0,0 +1,140 @@
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
/// Represents a topic in the course planner response.
class Topic {
final String title;
final String description;
final String location;
final String uuid;
final String? imageUrl;
final List<ActivityPlanModel> activities;
Topic({
required this.title,
required this.description,
this.location = "Unknown",
required this.uuid,
List<ActivityPlanModel>? activities,
this.imageUrl,
}) : activities = activities ?? [];
/// Deserialize from JSON
factory Topic.fromJson(Map<String, dynamic> json) {
return Topic(
title: json['title'] as String,
description: json['description'] as String,
location: json['location'] as String? ?? "Unknown",
uuid: json['uuid'] as String,
activities: (json['activities'] as List<dynamic>?)
?.map(
(e) => ActivityPlanModel.fromJson(e as Map<String, dynamic>),
)
.toList() ??
[],
imageUrl: json['image_url'] as String?,
);
}
/// Serialize to JSON
Map<String, dynamic> toJson() {
return {
'title': title,
'description': description,
'location': location,
'uuid': uuid,
'activities': activities.map((e) => e.toJson()).toList(),
'image_url': imageUrl,
};
}
List<String> get activityIds => activities.map((e) => e.bookmarkId).toList();
}
/// Represents a course plan in the course planner response.
class CoursePlanModel {
final String targetLanguage;
final String languageOfInstructions;
final LanguageLevelTypeEnum cefrLevel;
final String title;
final String description;
final String uuid;
final List<Topic> topics;
final String? imageUrl;
CoursePlanModel({
required this.targetLanguage,
required this.languageOfInstructions,
required this.cefrLevel,
required this.title,
required this.description,
required this.uuid,
List<Topic>? topics,
this.imageUrl,
}) : topics = topics ?? [];
int get activities =>
topics.map((t) => t.activities.length).reduce((a, b) => a + b);
LanguageModel? get targetLanguageModel =>
PLanguageStore.byLangCode(targetLanguage);
LanguageModel? get baseLanguageModel =>
PLanguageStore.byLangCode(languageOfInstructions);
String get targetLanguageDisplay =>
targetLanguageModel?.langCode.toUpperCase() ??
targetLanguage.toUpperCase();
String get baseLanguageDisplay =>
baseLanguageModel?.langCode.toUpperCase() ??
languageOfInstructions.toUpperCase();
String? topicID(String activityID) {
for (final topic in topics) {
for (final activity in topic.activities) {
if (activity.bookmarkId == activityID) {
return topic.uuid;
}
}
}
return null;
}
/// Deserialize from JSON
factory CoursePlanModel.fromJson(Map<String, dynamic> json) {
return CoursePlanModel(
targetLanguage: json['target_language'] as String,
languageOfInstructions: json['language_of_instructions'] as String,
cefrLevel: LanguageLevelTypeEnumExtension.fromString(json['cefr_level']),
title: json['title'] as String,
description: json['description'] as String,
uuid: json['uuid'] as String,
topics: (json['topics'] as List<dynamic>?)
?.map((e) => Topic.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
imageUrl: json['image_url'] as String?,
);
}
/// Serialize to JSON
Map<String, dynamic> toJson() {
return {
'target_language': targetLanguage,
'language_of_instructions': languageOfInstructions,
'cefr_level': cefrLevel.string,
'title': title,
'description': description,
'uuid': uuid,
'topics': topics.map((e) => e.toJson()).toList(),
'image_url': imageUrl,
};
}
}

View file

@ -0,0 +1,115 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/courses/course_plan_event.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/courses/course_user_event.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
extension CoursePlanRoomExtension on Room {
CoursePlanEvent? get coursePlan {
final event = getState(PangeaEventTypes.coursePlan);
if (event == null) return null;
return CoursePlanEvent.fromJson(event.content);
}
CourseUserState? _courseUserState(String userID) {
final event = getState(
PangeaEventTypes.courseUser,
userID,
);
if (event == null) return null;
return CourseUserState.fromJson(event.content);
}
CourseUserState? get _ownCourseState => _courseUserState(client.userID!);
bool _hasCompletedTopic(
String userID,
String topicID,
CoursePlanModel course,
) {
final state = _courseUserState(userID);
if (state == null) return false;
final topicIndex = course.topics.indexWhere(
(t) => t.uuid == topicID,
);
if (topicIndex == -1) {
throw Exception('Topic not found');
}
final activityIds =
course.topics[topicIndex].activities.map((a) => a.bookmarkId).toList();
return state.completedActivities(topicID).toSet().containsAll(activityIds);
}
Topic? currentTopic(
String userID,
CoursePlanModel course,
) {
if (coursePlan == null) return null;
final topicIDs = course.topics.map((t) => t.uuid).toList();
if (topicIDs.isEmpty) return null;
final index = topicIDs.indexWhere(
(t) => !_hasCompletedTopic(userID, t, course),
);
return index == -1 ? null : course.topics[index];
}
Topic? ownCurrentTopic(CoursePlanModel course) =>
currentTopic(client.userID!, course);
int currentTopicIndex(
String userID,
CoursePlanModel course,
) {
if (coursePlan == null) return -1;
final topicIDs = course.topics.map((t) => t.uuid).toList();
if (topicIDs.isEmpty) return -1;
final index = topicIDs.indexWhere(
(t) => !_hasCompletedTopic(userID, t, course),
);
return index == -1 ? 0 : index;
}
int ownCurrentTopicIndex(CoursePlanModel course) =>
currentTopicIndex(client.userID!, course);
Map<String, List<User>> topicsToUsers(CoursePlanModel course) {
final Map<String, List<User>> topicUserMap = {};
final users = getParticipants();
for (final user in users) {
if (user.id == BotName.byEnvironment) continue;
final topicIndex = currentTopicIndex(user.id, course);
if (topicIndex != -1) {
final topicID = course.topics[topicIndex].uuid;
topicUserMap.putIfAbsent(topicID, () => []).add(user);
}
}
return topicUserMap;
}
Future<void> finishCourseActivity(
String activityID,
String topicID,
) async {
CourseUserState? state = _ownCourseState;
state ??= CourseUserState(
userID: client.userID!,
completedActivities: {},
);
state.completeActivity(activityID, topicID);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.courseUser,
client.userID!,
state.toJson(),
);
}
}

View file

@ -0,0 +1,97 @@
import 'package:collection/collection.dart';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/courses/test_courses_json.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
class CourseFilter {
final LanguageModel? targetLanguage;
final LanguageModel? languageOfInstructions;
final LanguageLevelTypeEnum? cefrLevel;
CourseFilter({
this.targetLanguage,
this.languageOfInstructions,
this.cefrLevel,
});
}
class CourseRepo {
static final GetStorage _courseStorage = GetStorage("course_storage");
static CoursePlanModel? _getCached(String id) {
final json = _courseStorage.read(id);
if (json != null) {
try {
return CoursePlanModel.fromJson(json);
} catch (e) {
_courseStorage.remove(id);
}
}
return null;
}
static List<CoursePlanModel> _getAllCached() {
final keys = _courseStorage.getKeys();
return keys
.map((key) => _getCached(key))
.whereType<CoursePlanModel>()
.toList();
}
static Future<void> set(CoursePlanModel coursePlan) async {
await _courseStorage.write(coursePlan.uuid, coursePlan.toJson());
}
static Future<CoursePlanModel?> get(String id) async {
final cached = _getCached(id);
if (cached != null) {
return cached;
}
final resp = await search();
return resp.firstWhereOrNull((course) => course.uuid == id);
}
static Future<List<CoursePlanModel>> search({CourseFilter? filter}) async {
final cached = _getAllCached();
if (cached.isNotEmpty) {
return cached.filtered(filter);
}
final resp = (courseJson["courses"] as List<dynamic>)
.map((json) => CoursePlanModel.fromJson(json))
.whereType<CoursePlanModel>()
.toList();
for (final plan in resp) {
set(plan);
}
return resp.filtered(filter);
}
}
extension on List<CoursePlanModel> {
List<CoursePlanModel> filtered(CourseFilter? filter) {
return where((course) {
final matchesTargetLanguage = filter?.targetLanguage == null ||
course.targetLanguage.split("-").first ==
filter?.targetLanguage?.langCodeShort;
final matchesLanguageOfInstructions =
filter?.languageOfInstructions == null ||
course.languageOfInstructions.split("-").first ==
filter?.languageOfInstructions?.langCodeShort;
final matchesCefrLevel =
filter?.cefrLevel == null || course.cefrLevel == filter?.cefrLevel;
return matchesTargetLanguage &&
matchesLanguageOfInstructions &&
matchesCefrLevel;
}).toList();
}
}

View file

@ -0,0 +1,45 @@
class CourseUserState {
final String userID;
final Map<String, List<String>> _completedActivities;
CourseUserState({
required this.userID,
required Map<String, List<String>> completedActivities,
}) : _completedActivities = completedActivities;
void completeActivity(
String activityID,
String topicID,
) {
_completedActivities[topicID] ??= [];
if (!_completedActivities[topicID]!.contains(activityID)) {
_completedActivities[topicID]!.add(activityID);
}
}
List<String> completedActivities(String topicID) {
return _completedActivities[topicID] ?? [];
}
factory CourseUserState.fromJson(Map<String, dynamic> json) {
final Map<String, List<String>> activities = {};
final activityEntry =
(json['comp_act_by_topic'] as Map<String, dynamic>?) ?? {};
for (final entry in activityEntry.entries) {
activities[entry.key] = List<String>.from(entry.value);
}
return CourseUserState(
userID: json['user_id'],
completedActivities: activities,
);
}
Map<String, dynamic> toJson() {
return {
'user_id': userID,
'comp_act_by_topic': _completedActivities,
};
}
}

File diff suppressed because it is too large Load diff

View file

@ -47,6 +47,9 @@ class PangeaEventTypes {
/// Profile information related to a user's analytics
static const profileAnalytics = "pangea.analytics_profile";
static const profileActivities = "pangea.activities_profile";
static const activityRoomIds = "pangea.activity_room_ids";
/// Relates to course plans
static const coursePlan = "pangea.course_plan";
static const courseUser = "p.course_user";
}

View file

@ -1,6 +1,15 @@
part of "pangea_room_extension.dart";
extension ChildrenAndParentsRoomExtension on Room {
Room? get firstSpaceParent {
for (final parent in spaceParents) {
if (parent.roomId == null) continue;
final room = client.getRoomById(parent.roomId!);
if (room != null) return room;
}
return null;
}
List<Room> get pangeaSpaceParents => client.rooms
.where(
(r) => r.isSpace,
@ -37,22 +46,10 @@ extension ChildrenAndParentsRoomExtension on Room {
}
}
try {
await _trySetSpaceChild(
roomId,
suggested: suggested,
);
} catch (err, stack) {
ErrorHandler.logError(
e: err,
s: stack,
data: {
"roomID": roomId,
"childID": child.id,
"suggested": suggested,
},
);
}
await _trySetSpaceChild(
roomId,
suggested: suggested,
);
}
Future<void> _trySetSpaceChild(

View file

@ -64,16 +64,14 @@ class FindYourPeopleView extends StatelessWidget {
),
],
),
floatingActionButton: isColumnMode
? null
: FloatingActionButton.extended(
onPressed: () => context.push('/rooms/newspace'),
icon: const Icon(Icons.add_box_outlined),
label: Text(
L10n.of(context).space,
overflow: TextOverflow.fade,
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/rooms/communities/newcourse'),
icon: const Icon(Icons.add_box_outlined),
label: Text(
L10n.of(context).newCourse,
overflow: TextOverflow.fade,
),
),
body: Padding(
padding: isColumnMode
? const EdgeInsets.symmetric(
@ -194,30 +192,6 @@ class FindYourPeopleView extends StatelessWidget {
context,
),
),
TextButton(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.add_box_outlined,
color: theme
.colorScheme.onPrimaryContainer,
size: 24.0,
),
const SizedBox(width: 8.0),
Text(
L10n.of(context).createYourSpace,
style: TextStyle(
color: theme
.colorScheme.onPrimaryContainer,
fontSize: 16.0,
),
),
],
),
onPressed: () =>
context.push('/rooms/newspace'),
),
],
),
],

View file

@ -40,7 +40,7 @@ class PublicRoomBottomSheet extends StatefulWidget {
.getRoomById(chunk!.roomId);
if (room != null && room.membership == Membership.join) {
context.go("/rooms?spaceId=${room.id}");
context.go("/rooms/spaces/${room.id}/details");
return null;
}
@ -100,7 +100,7 @@ class PublicRoomBottomSheetState extends State<PublicRoomBottomSheet> {
if (chunk?.roomType != 'm.space' && !client.getRoomById(roomID)!.isSpace) {
outerContext.go("/rooms/$roomID");
} else {
context.go('/rooms?spaceId=$roomID');
context.go('/rooms/spaces/$roomID/details');
}
}

View file

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
@ -24,289 +23,272 @@ class SpaceAnalyticsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).spaceAnalytics),
centerTitle: true,
),
body: LayoutBuilder(
builder: (context, constraints) {
final mini = constraints.maxWidth <= 550;
return Padding(
padding: EdgeInsets.all(!mini ? 16.0 : 8.0),
child: MaxWidthBody(
maxWidth: 1000,
showBorder: false,
child: Column(
spacing: !mini ? 24.0 : 12.0,
return LayoutBuilder(
builder: (context, constraints) {
final mini = constraints.maxWidth <= 550;
return MaxWidthBody(
maxWidth: 1000,
showBorder: false,
child: Column(
spacing: !mini ? 24.0 : 12.0,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: !mini ? 12.0 : 4.0,
children: [
Row(
spacing: !mini ? 12.0 : 4.0,
children: [
_MenuButton(
text: L10n.of(context).requestAll,
icon: Symbols.approval_delegation,
onPressed: controller.requestAllAnalytics,
mini: mini,
hideLabel: false,
),
if (controller.room != null &&
controller.availableAnalyticsRooms.isNotEmpty &&
kIsWeb)
_MenuButton(
text: L10n.of(context).download,
icon: Icons.download,
onPressed: () {
showDialog(
context: context,
builder: (context) => DownloadAnalyticsDialog(
space: controller.room!,
analyticsRooms:
controller.availableAnalyticsRooms,
),
);
},
mini: mini,
),
],
_MenuButton(
text: L10n.of(context).requestAll,
icon: Symbols.approval_delegation,
onPressed: controller.requestAllAnalytics,
mini: mini,
hideLabel: false,
),
Row(
spacing: !mini ? 12.0 : 4.0,
children: [
if (controller.lastUpdatedString != null)
Text(
L10n.of(context).lastUpdated(
controller.lastUpdatedString!,
if (controller.room != null &&
controller.availableAnalyticsRooms.isNotEmpty)
_MenuButton(
text: L10n.of(context).download,
icon: Icons.download,
onPressed: () {
showDialog(
context: context,
builder: (context) => DownloadAnalyticsDialog(
space: controller.room!,
analyticsRooms:
controller.availableAnalyticsRooms,
),
textAlign: TextAlign.end,
style: TextStyle(
fontSize: !mini ? 12.0 : 8.0,
color: theme.disabledColor,
),
),
_MenuButton(
text: L10n.of(context).refresh,
icon: Symbols.refresh,
onPressed: controller.refresh,
mini: mini,
),
DropdownButtonHideUnderline(
child: DropdownButton2<LanguageModel>(
customButton: Container(
height: !mini ? 36.0 : 26.0,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(40),
),
padding: EdgeInsets.symmetric(
horizontal: !mini ? 8.0 : 4.0,
vertical: 4.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
if (controller.selectedLanguage != null)
Text(
controller.selectedLanguage!.langCode
.toUpperCase(),
style: TextStyle(
color: theme
.colorScheme.onPrimaryContainer,
fontSize: !mini ? 16.0 : 12.0,
),
),
Icon(
Icons.arrow_drop_down,
color:
theme.colorScheme.onPrimaryContainer,
size: !mini ? 24.0 : 14.0,
),
],
),
),
value: controller.selectedLanguage,
items: controller.availableLanguages
.map(
(item) => DropdownMenuItem(
value: item,
child: DropdownTextButton(
text: item.getDisplayName(context) ??
item.displayName,
isSelected: false,
),
),
)
.toList(),
onChanged: controller.setSelectedLanguage,
buttonStyleData: ButtonStyleData(
// This is necessary for the ink response to match our customButton radius.
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
),
),
dropdownStyleData: const DropdownStyleData(
offset: Offset(-160, 0),
width: 250,
),
),
),
],
),
);
},
mini: mini,
),
],
),
controller.initialized
? Table(
columnWidths: const {0: FlexColumnWidth(2.5)},
children: [
TableRow(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor),
),
),
Row(
spacing: !mini ? 12.0 : 4.0,
children: [
if (controller.lastUpdatedString != null)
Text(
L10n.of(context).lastUpdated(
controller.lastUpdatedString!,
),
textAlign: TextAlign.end,
style: TextStyle(
fontSize: !mini ? 12.0 : 8.0,
color: theme.disabledColor,
),
),
_MenuButton(
text: L10n.of(context).refresh,
icon: Symbols.refresh,
onPressed: controller.refresh,
mini: mini,
),
DropdownButtonHideUnderline(
child: DropdownButton2<LanguageModel>(
customButton: Container(
height: !mini ? 36.0 : 26.0,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(40),
),
padding: EdgeInsets.symmetric(
horizontal: !mini ? 8.0 : 4.0,
vertical: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_TableHeaderCell(
text: L10n.of(context).viewingAnalytics(
controller.completedDownloads,
controller.downloads.length,
if (controller.selectedLanguage != null)
Text(
controller.selectedLanguage!
.getDisplayName(context) ??
controller
.selectedLanguage!.displayName,
style: TextStyle(
color:
theme.colorScheme.onPrimaryContainer,
fontSize: !mini ? 16.0 : 12.0,
),
),
icon: Icons.group_outlined,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).level,
icon: Icons.star,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).vocab,
icon: Symbols.dictionary,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).grammar,
icon: Symbols.toys_and_games,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).activities,
icon: Icons.radar,
mini: mini,
Icon(
Icons.arrow_drop_down,
color: theme.colorScheme.onPrimaryContainer,
size: !mini ? 24.0 : 14.0,
),
],
),
...controller.sortedDownloads.mapIndexed(
(index, entry) {
final download = entry.value;
return TableRow(
children: [
TableCell(
child: Opacity(
opacity: download.requestStatus.opacity,
child: Padding(
padding: EdgeInsets.symmetric(
vertical: !mini ? 12.0 : 4.0,
),
child: Row(
spacing: !mini ? 16.0 : 8.0,
children: [
Avatar(
size: !mini ? 64.0 : 40.0,
mxContent: entry.key.avatarUrl,
name:
entry.key.calcDisplayname(),
userId: entry.key.id,
presenceUserId: entry.key.id,
),
Flexible(
child: Column(
spacing: 4.0,
mainAxisSize:
MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
SizedBox(
height: index == 0
? 8.0
: 0.0,
),
Text(
entry.key.id,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: TextStyle(
fontSize:
!mini ? 16.0 : 12.0,
fontWeight:
FontWeight.w500,
),
),
_RequestButton(
status: download
.requestStatus,
onPressed: () =>
controller
.requestAnalytics(
entry.key,
),
mini: mini,
),
const SizedBox(height: 8.0),
],
),
),
],
),
),
),
),
_TableContentCell(
text: download.summary?.level?.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
_TableContentCell(
text: download.summary?.numLemmas
.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
_TableContentCell(
text: download.summary?.numMorphConstructs
.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
_TableContentCell(
text: download
.summary?.numCompletedActivities
.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
],
);
},
),
value: controller.selectedLanguage,
items: controller.availableLanguages
.map(
(item) => DropdownMenuItem(
value: item,
child: DropdownTextButton(
text: item.getDisplayName(context) ??
item.displayName,
isSelected: false,
),
),
)
.toList(),
onChanged: controller.setSelectedLanguage,
buttonStyleData: ButtonStyleData(
// This is necessary for the ink response to match our customButton radius.
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
),
],
)
: const CircularProgressIndicator.adaptive(),
),
dropdownStyleData: const DropdownStyleData(
offset: Offset(-160, 0),
width: 250,
),
),
),
],
),
],
),
),
);
},
),
controller.initialized
? Table(
columnWidths: const {0: FlexColumnWidth(2.5)},
children: [
TableRow(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor),
),
),
children: [
_TableHeaderCell(
text: L10n.of(context).viewingAnalytics(
controller.completedDownloads,
controller.downloads.length,
),
icon: Icons.group_outlined,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).level,
icon: Icons.star,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).vocab,
icon: Symbols.dictionary,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).grammar,
icon: Symbols.toys_and_games,
mini: mini,
),
_TableHeaderCell(
text: L10n.of(context).activities,
icon: Icons.radar,
mini: mini,
),
],
),
...controller.sortedDownloads.mapIndexed(
(index, entry) {
final download = entry.value;
return TableRow(
children: [
TableCell(
child: Opacity(
opacity: download.requestStatus.opacity,
child: Padding(
padding: EdgeInsets.symmetric(
vertical: !mini ? 12.0 : 4.0,
),
child: Row(
spacing: !mini ? 16.0 : 8.0,
children: [
Avatar(
size: !mini ? 64.0 : 40.0,
mxContent: entry.key.avatarUrl,
name: entry.key.calcDisplayname(),
userId: entry.key.id,
presenceUserId: entry.key.id,
),
Flexible(
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
SizedBox(
height:
index == 0 ? 8.0 : 0.0,
),
Text(
entry.key.id,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: TextStyle(
fontSize:
!mini ? 16.0 : 12.0,
fontWeight: FontWeight.w500,
),
),
_RequestButton(
status:
download.requestStatus,
onPressed: () => controller
.requestAnalytics(
entry.key,
),
mini: mini,
),
const SizedBox(height: 8.0),
],
),
),
],
),
),
),
),
_TableContentCell(
text: download.summary?.level?.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
_TableContentCell(
text: download.summary?.numLemmas.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
_TableContentCell(
text: download.summary?.numMorphConstructs
.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
_TableContentCell(
text: download.summary?.numCompletedActivities
.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
mini: mini,
),
],
);
},
),
],
)
: const CircularProgressIndicator.adaptive(),
],
),
);
},
);
}
}
@ -506,34 +488,22 @@ class _RequestButton extends StatelessWidget {
child: Opacity(
opacity: status.enabled ? 0.9 : 0.3,
child: Container(
padding: (status != RequestStatus.unavailable)
? const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
)
: null,
decoration: status != RequestStatus.unavailable
? BoxDecoration(
borderRadius: BorderRadius.circular(40),
color: status.backgroundColor(context),
)
: null,
child: Wrap(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
color: status.backgroundColor(context),
),
child: Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
if (status.icon != null)
Icon(
status.icon,
size: !mini ? 12.0 : 8.0,
),
Icon(
status.icon,
size: !mini ? 12.0 : 8.0,
),
Text(
status.label(context),
style: TextStyle(
fontSize: !mini ? 12.0 : 8.0,
fontStyle: status == RequestStatus.unavailable
? FontStyle.italic
: FontStyle.normal,
),
style: TextStyle(fontSize: !mini ? 12.0 : 8.0),
),
],
),

View file

@ -87,7 +87,7 @@ class ClassController extends BaseController {
Room? room = client.getRoomByAlias(alias) ?? client.getRoomById(alias);
if (room != null) {
room.isSpace
? context.go("/rooms?spaceId=${room.id}")
? context.go("/rooms/spaces/${room.id}/details")
: context.go("/rooms/${room.id}");
return;
}
@ -104,7 +104,7 @@ class ClassController extends BaseController {
}
room.isSpace
? context.go("/rooms?spaceId=${room.id}")
? context.go("/rooms/spaces/${room.id}/details")
: context.go("/rooms/${room.id}");
}
@ -154,7 +154,7 @@ class ClassController extends BaseController {
if (!(room?.isSpace ?? true)) {
context.go("/rooms/${alreadyJoined.first}");
} else {
context.go("/rooms?spaceId=${alreadyJoined.first}");
context.go("/rooms/spaces/${alreadyJoined.first}/details");
}
return null;
}
@ -212,7 +212,7 @@ class ClassController extends BaseController {
}
if (room.isSpace) {
context.go("/rooms?spaceId=${room.id}");
context.go("/rooms/spaces/${room.id}/details");
} else {
context.go("/rooms/${room.id}");
}

View file

@ -19,6 +19,8 @@ extension SpacesClientExtension on Client {
JoinRules joinRules = JoinRules.public,
Uint8List? avatar,
Uri? avatarUrl,
List<StateEvent>? initialState,
int spaceChild = 50,
}) async {
final roomId = await createRoom(
creationContent: {'type': RoomCreationTypes.mSpace},
@ -26,7 +28,10 @@ extension SpacesClientExtension on Client {
name: name.trim(),
powerLevelContentOverride: {'events_default': 100},
initialState: [
RoomDefaults.defaultSpacePowerLevels(userID!),
RoomDefaults.defaultSpacePowerLevels(
userID!,
spaceChild: spaceChild,
),
await pangeaJoinRules(
joinRules.toString().replaceAll('JoinRules.', ''),
),
@ -35,6 +40,7 @@ extension SpacesClientExtension on Client {
type: EventTypes.RoomAvatar,
content: {'url': avatarUrl.toString()},
),
if (initialState != null) ...initialState,
],
);

View file

@ -69,15 +69,8 @@ class LoadParticipantsUtilState extends State<LoadParticipantsUtil> {
}
}
List<User> filteredParticipants(String filter) {
final searchText = filter.toLowerCase();
final filtered = participants.where((user) {
final displayName = user.displayName?.toLowerCase() ?? '';
return displayName.contains(searchText) ||
user.id.toLowerCase().contains(searchText);
}).toList();
filtered.sort((a, b) {
List<User> sortedParticipants() {
participants.sort((a, b) {
if (a.id == BotName.byEnvironment) {
return 1;
}
@ -99,7 +92,7 @@ class LoadParticipantsUtilState extends State<LoadParticipantsUtil> {
return (bProfile?.level ?? 0).compareTo(aProfile?.level ?? 0);
});
return filtered;
return participants;
}
Future<void> _cacheLevels() async {

View file

@ -48,7 +48,7 @@ class LeaderboardParticipantListState
space: widget.space,
builder: (participantsLoader) {
final participants = participantsLoader
.filteredParticipants("")
.sortedParticipants()
.where((p) => p.membership == Membership.join)
.toList();

View file

@ -1,146 +0,0 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/space_view.dart';
class SpaceViewAppbar extends StatelessWidget {
final Function(SpaceActions) onSpaceAction;
final VoidCallback onBack;
final List<Room>? joinedParents;
final Function(String) toParentSpace;
final Room? room;
const SpaceViewAppbar({
super.key,
required this.onSpaceAction,
required this.onBack,
required this.toParentSpace,
this.joinedParents,
this.room,
});
@override
Widget build(BuildContext context) {
final displayname =
room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound;
return GestureDetector(
onTap: () => onSpaceAction(SpaceActions.settings),
child: AppBar(
automaticallyImplyLeading: false,
titleSpacing: joinedParents?.isNotEmpty ?? false ? 0.0 : null,
title: Row(
children: [
if (joinedParents?.isNotEmpty ?? false)
IconButton(
icon: const Icon(Icons.arrow_back_outlined),
onPressed: () => toParentSpace(joinedParents!.first.id),
),
Flexible(
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 16),
),
if (room != null)
Text(
L10n.of(context).countChatsAndCountParticipants(
room!.spaceChildren.length,
room!.summary.mJoinedMemberCount ?? 1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
],
),
),
],
),
actions: [
PopupMenuButton<SpaceActions>(
useRootNavigator: true,
onSelected: onSpaceAction,
itemBuilder: (context) => [
PopupMenuItem(
value: SpaceActions.settings,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.settings_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).settings),
],
),
),
PopupMenuItem(
value: SpaceActions.invite,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.person_add_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).invite),
],
),
),
PopupMenuItem(
value: SpaceActions.groupChat,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Symbols.chat_add_on),
const SizedBox(width: 12),
Text(L10n.of(context).groupChat),
],
),
),
PopupMenuItem(
value: SpaceActions.subspace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add),
const SizedBox(width: 12),
Text(L10n.of(context).subspace),
],
),
),
PopupMenuItem(
value: SpaceActions.leave,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.logout_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).leave),
],
),
),
PopupMenuItem(
value: SpaceActions.delete,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).delete),
],
),
),
],
),
],
),
);
}
}

View file

@ -140,6 +140,8 @@ abstract class ClientManager {
PangeaEventTypes.activitySummary,
PangeaEventTypes.constructSummary,
PangeaEventTypes.activityRoomIds,
PangeaEventTypes.coursePlan,
PangeaEventTypes.courseUser,
// Pangea#
},
logLevel: kReleaseMode ? Level.warning : Level.verbose,

View file

@ -1,21 +1,30 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/settings/settings.dart';
import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart';
import 'package:fluffychat/pangea/find_your_people/find_your_people_constants.dart';
import 'package:fluffychat/widgets/navigation_rail.dart';
class TwoColumnLayout extends StatelessWidget {
final Widget mainView;
final Widget sideView;
// #Pangea
final Color? dividerColor;
// final Widget mainView;
final GoRouterState state;
// Pangea#
final Widget sideView;
const TwoColumnLayout({
super.key,
required this.mainView,
required this.sideView,
// #Pangea
this.dividerColor,
// required this.mainView,
required this.state,
// Pangea#
required this.sideView,
});
@override
Widget build(BuildContext context) {
@ -25,19 +34,37 @@ class TwoColumnLayout extends StatelessWidget {
child: Scaffold(
body: Row(
children: [
Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(),
width: FluffyThemes.columnWidth + FluffyThemes.navRailWidth,
child: mainView,
),
Container(
width: 1.0,
// #Pangea
// color: theme.dividerColor,
color: dividerColor ?? theme.dividerColor,
// #Pangea
if (FluffyThemes.isColumnMode(context) ||
!(state.fullPath?.endsWith(":roomid") ?? false)) ...[
SpacesNavigationRail(
activeSpaceId: state.pathParameters['spaceid'],
path: state.fullPath,
),
Container(
color: Theme.of(context).dividerColor,
width: 1,
),
],
if (FluffyThemes.isColumnMode(context)) ...[
// Pangea#
),
Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(),
// #Pangea
// width: FluffyThemes.columnWidth + FluffyThemes.navRailWidth,
// child: mainView,
width: FluffyThemes.columnWidth,
child: _MainView(state: state),
// Pangea#
),
Container(
width: 1.0,
color: theme.dividerColor,
),
// Pangea#
],
// Pangea#
Expanded(
child: ClipRRect(
child: sideView,
@ -49,3 +76,59 @@ class TwoColumnLayout extends StatelessWidget {
);
}
}
// #Pangea
class _MainView extends StatelessWidget {
final GoRouterState state;
const _MainView({
required this.state,
});
String get _asset {
const defaultAsset = FindYourPeopleConstants.sideBearFileName;
if (state.fullPath == null || state.fullPath!.isEmpty) return defaultAsset;
if (state.fullPath!.contains('analytics')) {
return AnalyticsPageConstants.dinoBotFileName;
}
return defaultAsset;
}
@override
Widget build(BuildContext context) {
final path = state.fullPath;
if (path == null) {
return ChatList(
activeChat: state.pathParameters['roomid'],
activeSpaceId: state.pathParameters['spaceid'],
);
}
if (path.contains("settings")) {
return Settings(key: state.pageKey);
}
if (['communities', 'analytics'].any((p) => path.contains(p))) {
return Center(
child: SizedBox(
width: 250.0,
child: CachedNetworkImage(
imageUrl: "${AppConfig.assetsBaseURL}/$_asset",
errorWidget: (context, url, error) => const SizedBox(),
placeholder: (context, url) => const Center(
child: CircularProgressIndicator.adaptive(),
),
),
),
);
}
return ChatList(
activeChat: state.pathParameters['roomid'],
activeSpaceId: state.pathParameters['spaceid'],
);
}
}
// Pangea#

View file

@ -15,18 +15,18 @@ import 'package:fluffychat/widgets/matrix.dart';
class SpacesNavigationRail extends StatelessWidget {
final String? activeSpaceId;
final void Function() onGoToChats;
final void Function(String) onGoToSpaceId;
// #Pangea
final void Function()? clearActiveSpace;
// final void Function() onGoToChats;
// final void Function(String) onGoToSpaceId;
final String? path;
// Pangea#
const SpacesNavigationRail({
required this.activeSpaceId,
required this.onGoToChats,
required this.onGoToSpaceId,
// #Pangea
this.clearActiveSpace,
// required this.onGoToChats,
// required this.onGoToSpaceId,
required this.path,
// Pangea#
super.key,
});
@ -41,9 +41,8 @@ class SpacesNavigationRail extends StatelessWidget {
.path
.startsWith('/rooms/settings');
// #Pangea
final path = GoRouter.of(context).routeInformationProvider.value.uri.path;
final isAnalytics = path.contains('analytics');
final isCommunities = path.contains('communities');
final isAnalytics = path?.contains('analytics') ?? false;
final isCommunities = path?.contains('communities') ?? false;
final isColumnMode = FluffyThemes.isColumnMode(context);
final width = isColumnMode
@ -91,7 +90,6 @@ class SpacesNavigationRail extends StatelessWidget {
return NaviRailItem(
isSelected: isAnalytics,
onTap: () {
clearActiveSpace?.call();
context.go("/rooms/analytics");
},
backgroundColor: Colors.transparent,
@ -127,9 +125,7 @@ class SpacesNavigationRail extends StatelessWidget {
!isSettings &&
!isAnalytics &&
!isCommunities,
// Pangea#
onTap: onGoToChats,
// #Pangea
// onTap: onGoToChats,
// icon: const Padding(
// padding: EdgeInsets.all(10.0),
// child: Icon(Icons.forum_outlined),
@ -140,6 +136,7 @@ class SpacesNavigationRail extends StatelessWidget {
// ),
icon: const Icon(Icons.forum_outlined),
selectedIcon: const Icon(Icons.forum),
onTap: () => context.go("/rooms"),
// Pangea#
toolTip: L10n.of(context).chats,
unreadBadgeFilter: (room) => true,
@ -158,7 +155,6 @@ class SpacesNavigationRail extends StatelessWidget {
// toolTip: L10n.of(context).createNewSpace,
isSelected: isCommunities,
onTap: () {
clearActiveSpace?.call();
context.go('/rooms/communities');
},
icon: const Icon(Icons.groups),
@ -186,7 +182,9 @@ class SpacesNavigationRail extends StatelessWidget {
room,
);
} else {
onGoToSpaceId(rootSpaces[i].id);
context.go(
"/rooms/spaces/${rootSpaces[i].id}/details",
);
}
},
// Pangea#