feat: activity sessions

This commit is contained in:
ggurdin 2025-08-04 16:31:00 -04:00 committed by GitHub
parent f4bef7f3e4
commit 62140c56da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3738 additions and 2454 deletions

3
.env
View file

@ -11,4 +11,5 @@ RC_OFFERING_NAME = 'test'
STRIPE_MANAGEMENT_LINK = 'https://billing.stripe.com/p/login/test_9AQaI8d3O9lmaXe5kk'
SUPPORT_SPACE_ID = '!gqSNSkvwTpgumyjLsV:staging.pangea.chat'
SUPPORT_SPACE_ID = '!gqSNSkvwTpgumyjLsV:staging.pangea.chat'
GOOGLE_API_KEY = 'AIzaSyD1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q'

View file

@ -352,6 +352,25 @@ abstract class AppRoutes {
: null,
),
),
routes: [
GoRoute(
path: ':roomid',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ChatPage(
roomId: state.pathParameters['roomid']!,
eventId: state.uri.queryParameters['event'],
backButton: BackButton(
onPressed: () => context.go(
"/rooms/analytics?mode=activities",
),
),
),
),
redirect: loggedOutRedirect,
),
],
),
],
),

View file

@ -4896,7 +4896,6 @@
"newChatActivityDesc": "Make every group chat an adventure with Activity Planner! Set captivating topics and objectives for the group, and bring conversations to life with stunning images. Spark imaginative discussions and keep the fun flowing effortlessly!",
"exploreMore": "Explore more",
"wordFocusListeningMultipleChoice": "Which audio matches the word?",
"createActivity": "Create activity",
"startChat": "Start a chat",
"translationProblem": "Translation problem",
"perfectTranslation": "Perfect translation!",
@ -5080,6 +5079,7 @@
"reset": "Reset",
"errorGenerateActivityMessage": "Failed to generate activity",
"errorRegenerateActivityMessage": "Failed to regenerate activity",
"errorLaunchActivityMessage": "Failed to launch activity",
"errorFetchingActivitiesMessage": "Failed to fetch activities",
"errorFetchingDefinition": "Failed to fetch definition",
"errorProcessAnalytics": "Failed to process analytics",
@ -5092,5 +5092,46 @@
"errorFetchingTranslation": "Failed to fetch translation",
"errorFetchingActivity": "Failed to fetch activity",
"check": "Check",
"unableToFindRoom": "No chat or space found with that code. Please try again."
"unableToFindRoom": "No chat or space found with that code. Please try again.",
"createActivityPlan": "Create a new activity plan",
"saveAndLaunch": "Save and Launch",
"launchToSpace": "Launch to Space",
"numberOfActivities": "Number of Activity Sessions",
"minimumActivityParticipants": "Each Activity needs a minimum {count} of participant(s).",
"@minimumActivityParticipants": {
"type": "String",
"placeholders": {
"count": {
"type": "int"
}
}
},
"unjoinedActivityMessage": "Do you want to participate? Choose an open role!\nOr hang out and watch the show!",
"fullActivityMessage": "Feel free to watch the show! While there aren't any open roles to participate, you can view the chat!",
"confirmRole": "Confirm role",
"openRoleLabel": "OPEN",
"joinedTheActivity": "👋 {username} joined as {role}",
"@joinedTheActivity": {
"type": "String",
"placeholders": {
"username": {
"type": "String"
},
"role": {
"type": "String"
}
}
},
"finishedTheActivity": "🎯 {username} wrapped up this activity",
"@finishedTheActivity": {
"type": "String",
"placeholders": {
"username": {
"type": "String"
}
}
},
"endActivityTitle": "Wrapped up my part",
"endActivityDesc": "Did you complete the objectives?\nThis is your confirmation that you're stepping back from texting. But dont worry, the fun continues in the chat! Feel free to hang out and enjoy the show until everyone clicks 'Done'.",
"archiveToAnalytics": "Archive to Analytics"
}

View file

@ -5532,7 +5532,6 @@
"save": "Guardar",
"selectActivity": "Seleccionar actividad",
"wordFocusListeningMultipleChoice": "¿Qué audio coincide con la palabra?",
"createActivity": "Crear actividad",
"startChat": "Iniciar un chat",
"translationProblem": "Problema de traducción",
"perfectTranslation": "¡Traducción perfecta!",

View file

@ -3832,7 +3832,6 @@
"save": "Lưu",
"selectActivity": "Chọn hoạt động",
"wordFocusListeningMultipleChoice": "Âm thanh nào phù hợp với từ?",
"createActivity": "Tạo hoạt động",
"startChat": "Bắt đầu trò chuyện",
"translationProblem": "Vấn đề dịch thuật",
"perfectTranslation": "Dịch thuật hoàn hảo!",

View file

@ -24,7 +24,7 @@ class ArchiveController extends State<Archive> {
// #Pangea
//return archive = await Matrix.of(context).client.loadArchive();
return archive = (await Matrix.of(context).client.loadArchive())
.where((e) => (!e.isSpace && !e.isAnalyticsRoom))
.where((e) => (!e.isSpace && !e.isHiddenRoom))
.toList();
// Pangea#
}

View file

@ -81,11 +81,18 @@ class ChatPage extends StatelessWidget {
final List<ShareItem>? shareItems;
final String? eventId;
// #Pangea
final Widget? backButton;
// Pangea#
const ChatPage({
super.key,
required this.roomId,
this.eventId,
this.shareItems,
// #Pangea
this.backButton,
// Pangea#
});
@override
@ -122,6 +129,9 @@ class ChatPage extends StatelessWidget {
room: room,
shareItems: shareItems,
eventId: eventId,
// #Pangea
backButton: backButton,
// Pangea#
);
}
}
@ -131,11 +141,18 @@ class ChatPageWithRoom extends StatefulWidget {
final List<ShareItem>? shareItems;
final String? eventId;
// #Pangea
final Widget? backButton;
// Pangea#
const ChatPageWithRoom({
super.key,
required this.room,
this.shareItems,
this.eventId,
// #Pangea
this.backButton,
// Pangea#
});
@override

View file

@ -13,6 +13,9 @@ import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart';
import 'package:fluffychat/pages/chat/chat_app_bar_title.dart';
import 'package:fluffychat/pages/chat/chat_event_list.dart';
import 'package:fluffychat/pages/chat/pinned_events.dart';
import 'package:fluffychat/pangea/activity_planner/activity_pinned_message.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_planner/activity_status_message.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_input_bar.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_input_bar_header.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_view_background.dart';
@ -24,7 +27,9 @@ import 'package:fluffychat/widgets/mxc_image.dart';
import 'package:fluffychat/widgets/unread_rooms_badge.dart';
import '../../utils/stream_extension.dart';
enum _EventContextAction { info, report }
// #Pangea
// enum _EventContextAction { info, report }
// Pangea#
class ChatView extends StatelessWidget {
final ChatController controller;
@ -210,31 +215,34 @@ class ChatView extends StatelessWidget {
// : theme.colorScheme.tertiaryContainer,
// Pangea#
automaticallyImplyLeading: false,
leading:
leading: controller.selectMode
? IconButton(
icon: const Icon(Icons.close),
onPressed: controller.clearSelectedEvents,
tooltip: L10n.of(context).close,
color: theme.colorScheme.onTertiaryContainer,
)
// #Pangea
// controller.selectMode
// ? IconButton(
// icon: const Icon(Icons.close),
// onPressed: controller.clearSelectedEvents,
// tooltip: L10n.of(context).close,
// color: theme.colorScheme.onTertiaryContainer,
// )
// :
// Pangea#
FluffyThemes.isColumnMode(context)
? null
: StreamBuilder<Object>(
stream:
Matrix.of(context).client.onSync.stream.where(
: controller.widget.backButton != null
? controller.widget.backButton!
// Pangea#
: FluffyThemes.isColumnMode(context)
? null
: StreamBuilder<Object>(
stream: Matrix.of(context)
.client
.onSync
.stream
.where(
(syncUpdate) => syncUpdate.hasRoomUpdate,
),
builder: (context, _) => UnreadRoomsBadge(
filter: (r) => r.id != controller.roomId,
badgePosition:
BadgePosition.topEnd(end: 8, top: 4),
child: const Center(child: BackButton()),
),
),
builder: (context, _) => UnreadRoomsBadge(
filter: (r) => r.id != controller.roomId,
badgePosition:
BadgePosition.topEnd(end: 8, top: 4),
child: const Center(child: BackButton()),
),
),
titleSpacing: FluffyThemes.isColumnMode(context) ? 24 : 0,
title: ChatAppBarTitle(controller),
actions: _appBarActions(context),
@ -413,13 +421,18 @@ class ChatView extends StatelessWidget {
),
// #Pangea
// Keep messages above minimum input bar height
if (!controller.room.isAbandonedDMRoom)
if (!controller.room.isAbandonedDMRoom &&
controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join &&
controller.room.hasJoinedActivity &&
!controller.room.hasFinishedActivity)
AnimatedSize(
duration: const Duration(milliseconds: 200),
child: SizedBox(
height: controller.inputBarHeight,
),
),
ActivityStatusMessage(room: controller.room),
// Pangea#
],
),
@ -427,7 +440,9 @@ class ChatView extends StatelessWidget {
ChatViewBackground(controller.choreographer),
if (!controller.room.isAbandonedDMRoom &&
controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join)
controller.room.membership == Membership.join &&
controller.room.hasJoinedActivity &&
!controller.room.hasFinishedActivity)
Positioned(
left: 0,
right: 0,
@ -447,6 +462,7 @@ class ChatView extends StatelessWidget {
],
),
),
ActivityPinnedMessage(controller),
// Pangea#
],
),

View file

@ -10,7 +10,10 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/pangea_message_reactions.dart';
import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart';
import 'package:fluffychat/pangea/chat/widgets/activity_role_state_message.dart';
import 'package:fluffychat/pangea/chat/widgets/activity_state_event.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.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/utils/date_time_extension.dart';
import 'package:fluffychat/utils/file_description.dart';
@ -125,6 +128,17 @@ class Message extends StatelessWidget {
if (event.type == EventTypes.RoomCreate) {
return RoomCreationStateEvent(event: event);
}
// #Pangea
if (event.type == PangeaEventTypes.activityPlan) {
return ActivityStateEvent(event: event);
}
if (event.type == PangeaEventTypes.activityRole) {
return ActivityRoleStateMessage(event);
}
// Pangea#
return StateMessage(event);
}

View file

@ -10,14 +10,12 @@ 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/utils/download_chat.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/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';
@ -315,50 +313,9 @@ class ChatDetailsController extends State<ChatDetails> {
}
Future<void> addSubspace() async {
final names = await showTextInputDialog(
context: context,
title: L10n.of(context).createNewSpace,
hintText: L10n.of(context).spaceName,
minLines: 1,
maxLines: 1,
maxLength: 64,
validator: (text) {
if (text.isEmpty) {
return L10n.of(context).pleaseChoose;
}
return null;
},
okLabel: L10n.of(context).create,
cancelLabel: L10n.of(context).cancel,
);
if (names == null) return;
final client = Matrix.of(context).client;
await showFutureLoadingDialog(
context: context,
future: () async {
final activeSpace = client.getRoomById(roomId!)!;
await activeSpace.postLoad();
final resp = await client.createRoom(
name: names,
visibility: RoomDefaults.spaceChildVisibility,
creationContent: {'type': 'm.space'},
initialState: [
RoomDefaults.defaultSpacePowerLevels(client.userID!),
await client.pangeaJoinRules(
'knock_restricted',
allow: [
{
"type": "m.room_membership",
"room_id": roomId!,
}
],
),
],
);
await activeSpace.addToSpace(resp);
},
);
final activeSpace = Matrix.of(context).client.getRoomById(roomId!);
if (activeSpace == null || !activeSpace.isSpace) return;
await activeSpace.addSubspace(context);
}
// Pangea#
}

View file

@ -265,24 +265,24 @@ class ChatListController extends State<ChatList>
case ActiveFilter.allChats:
// #Pangea
// return (room) => true;
return (room) => !room.isAnalyticsRoom && !room.isSpace;
return (room) => !room.isHiddenRoom && !room.isSpace;
// Pangea#
case ActiveFilter.messages:
// #Pangea
// return (room) => !room.isSpace && room.isDirectChat;
return (room) =>
!room.isSpace && room.isDirectChat && !room.isAnalyticsRoom;
!room.isSpace && room.isDirectChat && !room.isHiddenRoom;
// Pangea#
case ActiveFilter.groups:
// #Pangea
// return (room) => !room.isSpace && !room.isDirectChat;
return (room) =>
!room.isSpace && !room.isDirectChat && !room.isAnalyticsRoom;
!room.isSpace && !room.isDirectChat && !room.isHiddenRoom;
// Pangea#
case ActiveFilter.unread:
// #Pangea
// return (room) => room.isUnreadOrInvited;
return (room) => room.isUnreadOrInvited && !room.isAnalyticsRoom;
return (room) => room.isUnreadOrInvited && !room.isHiddenRoom;
// Pangea#
case ActiveFilter.spaces:
return (room) => room.isSpace;
@ -650,7 +650,7 @@ class ChatListController extends State<ChatList>
final room = client.getRoomById(roomID);
if (room == null ||
room.isSpace ||
room.isAnalyticsRoom ||
room.isHiddenRoom ||
room.capacity == null ||
(room.summary.mJoinedMemberCount ?? 1) <= room.capacity!) {
continue;
@ -1113,12 +1113,10 @@ class ChatListController extends State<ChatList>
if (confirmed != OkCancelResult.ok) return;
if (!mounted) return;
final resp = await showFutureLoadingDialog(
await showFutureLoadingDialog(
context: context,
future: room.delete,
);
if (resp.isError) return;
if (mounted) context.go("/rooms?spaceId=clear");
}
return;
// Pangea#

View file

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
@ -22,6 +21,7 @@ import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart';
import 'package:fluffychat/pangea/spaces/widgets/leaderboard_participant_list.dart';
import 'package:fluffychat/pangea/spaces/widgets/space_view_appbar.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
@ -391,6 +391,13 @@ class _SpaceViewState extends State<SpaceView> {
if (resp == true) {
context.go("/rooms?spaceId=clear");
}
break;
case SpaceActions.groupChat:
context.go("/rooms/newgroup?space=${widget.spaceId}");
break;
case SpaceActions.subspace:
space?.addSubspace(context);
break;
// Pangea#
}
}
@ -552,8 +559,10 @@ class _SpaceViewState extends State<SpaceView> {
final theme = Theme.of(context);
final room = Matrix.of(context).client.getRoomById(widget.spaceId);
final displayname =
room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound;
// #Pangea
// final displayname =
// room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound;
// Pangea#
// #Pangea
final joinedParents = room?.spaceParents
@ -567,167 +576,92 @@ class _SpaceViewState extends State<SpaceView> {
// Pangea#
return Scaffold(
// #Pangea
// appBar: AppBar(
appBar: PreferredSize(
preferredSize: Size.fromHeight(
kIsWeb ? 72.0 : (kToolbarHeight + MediaQuery.of(context).padding.top),
),
child: GestureDetector(
onTap: () {
_onSpaceAction(SpaceActions.settings);
},
child: AppBar(
// leading: FluffyThemes.isColumnMode(context)
// ? null
// : Center(
// child: CloseButton(
// onPressed: widget.onBack,
// ),
// ),
leading: joinedParents?.isEmpty ?? true
? FluffyThemes.isColumnMode(context)
? null
: Center(
child: CloseButton(
onPressed: widget.onBack,
),
)
: Center(
child: joinedParents!.length == 1
? IconButton(
icon: const Icon(Icons.arrow_back_outlined),
onPressed: () =>
widget.toParentSpace(joinedParents.first.id),
)
: PopupMenuButton(
tooltip: null,
useRootNavigator: true,
icon: const Icon(Icons.arrow_back_outlined),
itemBuilder: (context) {
return [
...joinedParents.mapIndexed((i, room) {
return PopupMenuItem(
value: i,
child: Text(room.getLocalizedDisplayname()),
);
}),
];
},
onSelected: (i) {
widget.toParentSpace(joinedParents[i].id);
},
),
),
// Pangea#
automaticallyImplyLeading: false,
titleSpacing: FluffyThemes.isColumnMode(context) ? null : 0,
title: ListTile(
contentPadding: EdgeInsets.zero,
leading: Avatar(
mxContent: room?.avatar,
name: displayname,
// #Pangea
userId: room?.directChatMatrixID,
// Pangea#
border: BorderSide(width: 1, color: theme.dividerColor),
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
),
title: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: room == null
? null
: Text(
L10n.of(context).countChatsAndCountParticipants(
// #Pangea
// room.spaceChildren.length,
room.spaceChildCount,
// Pangea#
room.summary.mJoinedMemberCount ?? 1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
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.leave,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// #Pangea
// const Icon(Icons.delete_outlined),
const Icon(Icons.logout_outlined),
// Pangea#
const SizedBox(width: 12),
Text(L10n.of(context).leave),
],
),
),
// #Pangea
if (Matrix.of(context)
.client
.getRoomById(widget.spaceId)
?.isRoomAdmin ??
false)
PopupMenuItem(
value: SpaceActions.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,
),
),
],
),
),
// Pangea#
],
),
],
),
preferredSize: const Size.fromHeight(60.0),
child: SpaceViewAppbar(
onSpaceAction: _onSpaceAction,
onBack: widget.onBack,
room: room,
toParentSpace: widget.toParentSpace,
joinedParents: joinedParents,
),
),
// appBar: AppBar(
// leading: FluffyThemes.isColumnMode(context)
// ? null
// : Center(
// child: CloseButton(
// onPressed: widget.onBack,
// ),
// ),
// automaticallyImplyLeading: false,
// titleSpacing: FluffyThemes.isColumnMode(context) ? null : 0,
// title: ListTile(
// contentPadding: EdgeInsets.zero,
// leading: Avatar(
// mxContent: room?.avatar,
// name: displayname,
// border: BorderSide(width: 1, color: theme.dividerColor),
// borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
// ),
// title: Text(
// displayname,
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
// ),
// subtitle: room == null
// ? null
// : Text(
// L10n.of(context).countChatsAndCountParticipants(
// room.spaceChildren.length,
// room.summary.mJoinedMemberCount ?? 1,
// ),
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// 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.leave,
// child: Row(
// mainAxisSize: MainAxisSize.min,
// children: [
// const Icon(Icons.delete_outlined),
// const SizedBox(width: 12),
// Text(L10n.of(context).leave),
// ],
// ),
// ),
// ],
// ),
// ],
// ),
floatingActionButton: room?.canChangeStateEvent(
EventTypes.SpaceChild,
) ==
@ -736,11 +670,12 @@ class _SpaceViewState extends State<SpaceView> {
// #Pangea
// onPressed: _addChatOrSubspace,
// label: Text(L10n.of(context).group),
// icon: const Icon(Icons.group_add_outlined),
onPressed: () =>
context.go("/rooms/newgroup?space=${widget.spaceId}"),
label: Text(L10n.of(context).groupChat),
context.go("/rooms/${widget.spaceId}/details/planner"),
label: Text(L10n.of(context).activities),
icon: const Icon(Icons.event_note_outlined),
// Pangea#
icon: const Icon(Icons.group_add_outlined),
)
: null,
body: room == null
@ -763,7 +698,7 @@ class _SpaceViewState extends State<SpaceView> {
final joinedRooms = room.client.rooms
.where((room) => childrenIds.remove(room.id))
// #Pangea
.where((room) => !room.isAnalyticsRoom)
.where((room) => !room.isHiddenRoom)
// Pangea#
.toList();
@ -1025,5 +960,7 @@ enum SpaceActions {
leave,
// #Pangea
delete,
groupChat,
subspace,
// Pangea#
}

View file

@ -5,6 +5,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart';
import 'package:fluffychat/pages/chat_permissions_settings/permission_list_tile.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -49,6 +50,8 @@ class ChatPermissionsSettingsView extends StatelessWidget {
controller.defaultPowerLevels,
);
final excludedEvents = [PangeaEventTypes.activityRole];
Map<String, dynamic> missingPowerLevels = Map<String, dynamic>.from(
defaults,
)..removeWhere((k, v) => v is! int || powerLevels.containsKey(k));
@ -72,6 +75,9 @@ class ChatPermissionsSettingsView extends StatelessWidget {
powerLevels.addAll(missingPowerLevels);
eventsPowerLevels.addAll(missingEventsPowerLevels);
eventsPowerLevels.removeWhere(
(key, value) => excludedEvents.contains(key),
);
// Pangea#
return Column(
children: [

View file

@ -9,6 +9,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/new_group/new_group_view.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart';

View file

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/new_group/new_group.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_carousel.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
@ -175,17 +174,6 @@ class NewGroupView extends StatelessWidget {
// onChanged: null,
// ),
// ),
if (controller.createGroupType == CreateGroupType.group)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: ActivitySuggestionCarousel(
onActivitySelected: controller.setSelectedActivity,
enabled: controller.nameController.text.isNotEmpty &&
!controller.loading,
selectedActivity: controller.selectedActivity,
selectedActivityImage: controller.selectedActivityImage,
),
),
// Pangea#
AnimatedSize(
duration: FluffyThemes.animationDuration,

View file

@ -21,9 +21,9 @@ import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_en
import 'package:fluffychat/widgets/matrix.dart';
class ActivityGenerator extends StatefulWidget {
final String? roomID;
final String roomID;
const ActivityGenerator({
this.roomID,
required this.roomID,
super.key,
});
@ -53,6 +53,7 @@ class ActivityGeneratorState extends State<ActivityGenerator> {
@override
void initState() {
super.initState();
selectedLanguageOfInstructions =
MatrixState.pangeaController.languageController.userL1?.langCode;
selectedTargetLanguage =
@ -96,9 +97,7 @@ class ActivityGeneratorState extends State<ActivityGenerator> {
Future<List<ActivitySettingResponseSchema>> get objectiveItems =>
LearningObjectiveListRepo.get(req);
Room? get room => widget.roomID != null
? Matrix.of(context).client.getRoomById(widget.roomID!)
: null;
Room? get room => Matrix.of(context).client.getRoomById(widget.roomID);
String? validateNotNull(String? value) {
if (value == null || value.isEmpty) {

View file

@ -36,7 +36,7 @@ class ActivityGeneratorView extends StatelessWidget {
padding: EdgeInsets.all(32.0),
child: Center(child: CircularProgressIndicator()),
);
} else if (controller.error != null) {
} else if (controller.error != null || controller.room == null) {
body = Center(
child: Column(
spacing: 16.0,
@ -61,7 +61,7 @@ class ActivityGeneratorView extends StatelessWidget {
return ActivityPlannerBuilder(
initialActivity: controller.activities![index],
initialFilename: controller.filename,
room: controller.room,
room: controller.room!,
builder: (c) {
return ActivityPlanCard(
regenerate: () => controller.generate(force: true),

View file

@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.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/pangea/activity_planner/activity_participant_indicator.dart';
import 'package:fluffychat/pangea/activity_planner/activity_results_carousel.dart';
import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class ActivityFinishedStatusMessage extends StatefulWidget {
final Room room;
const ActivityFinishedStatusMessage({
super.key,
required this.room,
});
@override
ActivityFinishedStatusMessageState createState() =>
ActivityFinishedStatusMessageState();
}
class ActivityFinishedStatusMessageState
extends State<ActivityFinishedStatusMessage> {
ActivityRoleModel? _highlightedRole;
bool _expanded = true;
@override
void initState() {
super.initState();
_setDefaultHighlightedRole();
}
@override
void didUpdateWidget(ActivityFinishedStatusMessage oldWidget) {
super.didUpdateWidget(oldWidget);
_setDefaultHighlightedRole();
}
void _setExpanded(bool expanded) {
if (mounted) setState(() => _expanded = expanded);
}
int get _hightlightedRoleIndex {
if (_highlightedRole == null) {
return -1; // No highlighted role
}
return widget.room.activityRoles.indexOf(_highlightedRole!);
}
void _setDefaultHighlightedRole() {
if (_hightlightedRoleIndex >= 0) return;
final roles = widget.room.activityRoles;
_highlightedRole = roles.firstWhereOrNull(
(r) => r.userId == widget.room.client.userID,
);
if (_highlightedRole == null && roles.isNotEmpty) {
_highlightedRole = roles.first;
}
if (mounted) setState(() {});
}
void _highlightRole(ActivityRoleModel role) {
if (mounted) setState(() => _highlightedRole = role);
}
bool get _canMoveLeft =>
_hightlightedRoleIndex > 0 && _highlightedRole != null;
bool get _canMoveRight =>
_hightlightedRoleIndex < widget.room.activityRoles.length - 1 &&
_highlightedRole != null;
void _moveLeft() {
if (_hightlightedRoleIndex > 0) {
_highlightRole(widget.room.activityRoles[_hightlightedRoleIndex - 1]);
}
}
void _moveRight() {
if (_hightlightedRoleIndex < widget.room.activityRoles.length - 1) {
_highlightRole(widget.room.activityRoles[_hightlightedRoleIndex + 1]);
}
}
Future<void> _archiveToAnalytics() async {
final role = widget.room.activityRole(widget.room.client.userID!);
if (role == null) {
throw Exception(
"Cannot archive activity without a role for user ${widget.room.client.userID!}",
);
}
role.archivedAt = DateTime.now();
await widget.room.archiveActivity();
await MatrixState.pangeaController.putAnalytics
.sendActivityAnalytics(widget.room.id);
}
@override
Widget build(BuildContext context) {
final summary = widget.room.activitySummary;
final imageURL = widget.room.activityPlan!.imageURL;
final theme = Theme.of(context);
final isColumnMode = MediaQuery.of(context).size.width < 600;
final user = widget.room.getParticipants().firstWhereOrNull(
(u) => u.id == _highlightedRole?.userId,
);
final userSummary =
widget.room.activitySummary?.participants.firstWhereOrNull(
(p) => p.participantId == _highlightedRole!.userId,
);
return AnimatedSize(
duration: FluffyThemes.animationDuration,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: _expanded
? [
if (summary != null) ...[
IconButton(
icon: Icon(
Icons.expand_more,
color: theme.colorScheme.onSurfaceVariant,
),
onPressed: () => _setExpanded(!_expanded),
),
const SizedBox(height: 8.0),
if (imageURL != null)
ClipRRect(
borderRadius: BorderRadius.circular(100),
child: imageURL.startsWith("mxc")
? MxcImage(
uri: Uri.parse(imageURL),
width: 100.0,
height: 100.0,
cacheKey: widget.room.activityPlan!.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: imageURL,
fit: BoxFit.cover,
width: 100.0,
height: 100.0,
placeholder: (
context,
url,
) =>
const Center(
child: CircularProgressIndicator(),
),
errorWidget: (
context,
url,
error,
) =>
const SizedBox(),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
summary.summary,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
),
if (_highlightedRole != null &&
user != null &&
userSummary != null)
ActivityResultsCarousel(
selectedRole: _highlightedRole!,
moveLeft: _canMoveLeft ? _moveLeft : null,
moveRight: _canMoveRight ? _moveRight : null,
user: user,
summary: userSummary,
),
const SizedBox(height: 8.0),
Wrap(
spacing: 12.0,
runSpacing: 12.0,
children: widget.room.activityRoles
.map(
(role) => Opacity(
opacity: _highlightedRole == role ? 1.0 : 0.5,
child: ActivityParticipantIndicator(
onTap: _highlightedRole == role
? null
: () => _highlightRole(role),
role: role,
displayname: role.userId.localpart,
),
),
)
.toList(),
),
const SizedBox(height: 20.0),
],
if (!widget.room.isHiddenActivityRoom)
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
foregroundColor: theme.colorScheme.onPrimaryContainer,
backgroundColor: theme.colorScheme.primaryContainer,
),
onPressed: () async {
final resp = await showFutureLoadingDialog(
context: context,
future: _archiveToAnalytics,
);
if (!resp.isError) {
context.go(
"/rooms/analytics?mode=activities",
);
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(L10n.of(context).archiveToAnalytics),
],
),
),
]
: [
if (summary != null)
IconButton(
icon: Icon(
Icons.expand_less,
color: theme.colorScheme.onSurfaceVariant,
),
onPressed: () => _setExpanded(!_expanded),
),
],
),
);
}
}

View file

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
class ActivityParticipantIndicator extends StatelessWidget {
final bool selected;
final ActivityRoleModel? role;
final String? displayname;
final VoidCallback? onTap;
const ActivityParticipantIndicator({
super.key,
this.selected = false,
this.role,
this.displayname,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AbsorbPointer(
absorbing: onTap == null,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: HoverBuilder(
builder: (context, hovered) {
return Opacity(
opacity: onTap == null ? 0.7 : 1.0,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0),
color: hovered || selected
? theme.colorScheme.primaryContainer.withAlpha(
selected ? 100 : 50,
)
: Colors.transparent,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 30.0,
backgroundColor: theme.colorScheme.primaryContainer,
),
Text(
role?.role ?? L10n.of(context).participant,
style: const TextStyle(
fontSize: 12.0,
),
),
Text(
displayname ?? L10n.of(context).openRoleLabel,
style: TextStyle(
fontSize: 12.0,
color: displayname?.lightColorAvatar ??
role?.role?.lightColorAvatar,
),
),
],
),
),
);
},
),
),
),
);
}
}

View file

@ -0,0 +1,187 @@
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/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class ActivityPinnedMessage extends StatefulWidget {
final ChatController controller;
const ActivityPinnedMessage(this.controller, {super.key});
@override
State<ActivityPinnedMessage> createState() => ActivityPinnedMessageState();
}
class ActivityPinnedMessageState extends State<ActivityPinnedMessage> {
bool _showDropdown = false;
Room get room => widget.controller.room;
void _scrollToActivity() {
final eventId = widget.controller.timeline?.events
.firstWhereOrNull(
(e) => e.type == PangeaEventTypes.activityPlan,
)
?.eventId;
if (eventId == null) return;
widget.controller.scrollToEventId(eventId);
}
void _setShowDropdown(bool value) {
if (value != _showDropdown) {
setState(() {
_showDropdown = value;
});
}
}
Future<void> _finishActivity() async {
final resp = await showFutureLoadingDialog(
context: context,
future: () async {
await room.finishActivity();
if (mounted) {
_setShowDropdown(false);
}
},
);
if (resp.isError) return;
if (room.activityIsFinished) {
await room.fetchSummaries();
}
}
@override
Widget build(BuildContext context) {
if (!room.hasJoinedActivity || room.activityPlan == null) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
return Positioned(
top: 0,
left: 0,
right: 0,
bottom: _showDropdown ? 0 : null,
child: Column(
children: [
AnimatedContainer(
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
color: _showDropdown
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surface,
),
child: ChatAppBarListTile(
title: room.activityPlan?.markdown ??
L10n.of(context).loadingPleaseWait,
leading: const SizedBox(width: 18.0),
trailing: Padding(
padding: const EdgeInsets.only(right: 12.0),
child: room.hasFinishedActivity
? null
: ElevatedButton(
onPressed:
_showDropdown ? null : () => _setShowDropdown(true),
style: ElevatedButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 4.0,
),
backgroundColor: theme.colorScheme.onSurface,
foregroundColor: theme.colorScheme.surface,
),
child: Text(
L10n.of(context).done,
style: const TextStyle(
fontSize: 12.0,
),
),
),
),
onTap: _scrollToActivity,
),
),
AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: Curves.easeInOut,
child: ClipRect(
child: _showDropdown
? Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
),
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 16.0,
),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
Text(
L10n.of(context).endActivityDesc,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.endActivityAssetPath}",
width: isColumnMode ? 240.0 : 120.0,
),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
foregroundColor: theme.colorScheme.onSecondary,
backgroundColor: theme.colorScheme.secondary,
),
onPressed: _finishActivity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).endActivityTitle,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
],
),
),
],
),
)
: const SizedBox.shrink(),
),
),
if (_showDropdown)
Expanded(
child: GestureDetector(
onTap: () => _setShowDropdown(false),
child: Container(color: Colors.black.withAlpha(100)),
),
),
],
),
);
}
}

View file

@ -12,12 +12,14 @@ import 'package:fluffychat/l10n/l10n.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/activity_planner/bookmarked_activities_repo.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_room_selection.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class ActivityPlanCard extends StatefulWidget {
final VoidCallback regenerate;
@ -70,38 +72,24 @@ class ActivityPlanCardState extends State<ActivityPlanCard> {
}
Future<void> _onLaunch() async {
if (widget.controller.room != null && !widget.controller.room!.isSpace) {
final resp = await showFutureLoadingDialog(
context: context,
future: widget.controller.launchToRoom,
);
if (!resp.isError) {
context.go("/rooms/${widget.controller.room!.id}");
}
return;
}
return showDialog(
final resp = await showFutureLoadingDialog(
context: context,
builder: (context) {
return FullWidthDialog(
dialogContent: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
child: ActivityRoomSelection(
controller: widget.controller,
backButton: IconButton(
onPressed: Navigator.of(context).pop,
icon: const Icon(Icons.close),
),
),
),
maxWidth: 400.0,
maxHeight: 650.0,
);
future: () async {
if (!widget.controller.room.isSpace) {
throw Exception(
"Cannot launch activity in a non-space room",
);
}
await widget.controller.launchToSpace();
context.go("/rooms?spaceId=${widget.controller.room.id}");
Navigator.of(context).pop();
},
);
if (!resp.isError) {
context.go("/rooms?spaceId=${widget.controller.room.id}");
}
}
bool get _isBookmarked => BookmarkedActivitiesRepo.isBookmarked(
@ -134,33 +122,47 @@ class ActivityPlanCardState extends State<ActivityPlanCard> {
),
clipBehavior: Clip.hardEdge,
alignment: Alignment.center,
child: widget.controller.imageURL != null ||
widget.controller.avatar != null
? ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: widget.controller.avatar == null
? CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: widget.controller.imageURL!,
placeholder: (context, url) {
return const Center(
child: CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return const Padding(
padding: EdgeInsets.all(28.0),
);
},
)
: Image.memory(
widget.controller.avatar!,
fit: BoxFit.cover,
),
child: widget.controller.isLaunching
? Avatar(
mxContent: widget.controller.room.avatar,
name: widget.controller.room
.getLocalizedDisplayname(
MatrixLocals(
L10n.of(context),
),
),
borderRadius: BorderRadius.circular(12.0),
size: 200.0,
)
: const Padding(
padding: EdgeInsets.all(28.0),
),
: widget.controller.imageURL != null ||
widget.controller.avatar != null
? ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: widget.controller.avatar == null
? CachedNetworkImage(
fit: BoxFit.cover,
imageUrl:
widget.controller.imageURL!,
placeholder: (context, url) {
return const Center(
child:
CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return const Padding(
padding: EdgeInsets.all(28.0),
);
},
)
: Image.memory(
widget.controller.avatar!,
fit: BoxFit.cover,
),
)
: const Padding(
padding: EdgeInsets.all(28.0),
),
),
if (widget.controller.isEditing)
InkWell(
@ -184,348 +186,534 @@ class ActivityPlanCardState extends State<ActivityPlanCard> {
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.event_note_outlined),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller:
widget.controller.titleController,
decoration: InputDecoration(
labelText: L10n.of(context).activityTitle,
children: widget.controller.isLaunching
? [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Avatar(
mxContent: widget.controller.room.avatar,
name: widget.controller.room
.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
),
size: 24.0,
borderRadius: BorderRadius.circular(4.0),
),
const SizedBox(width: itemPadding),
Expanded(
child: Text(
widget.controller.room
.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
),
maxLines: null,
)
: Text(
style:
Theme.of(context).textTheme.bodyLarge,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
widget.controller.updatedActivity.imageURL !=
null
? ClipRRect(
borderRadius:
BorderRadius.circular(4.0),
child: widget.controller.updatedActivity
.imageURL!
.startsWith("mxc")
? MxcImage(
uri: Uri.parse(
widget
.controller
.updatedActivity
.imageURL!,
),
width: 24.0,
height: 24.0,
cacheKey: widget.controller
.updatedActivity.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: widget.controller
.updatedActivity.imageURL!,
fit: BoxFit.cover,
width: 24.0,
height: 24.0,
placeholder: (
context,
url,
) =>
const Center(
child:
CircularProgressIndicator(),
),
errorWidget: (
context,
url,
error,
) =>
const SizedBox(),
),
)
: const Icon(
Icons.event_note_outlined,
size: 24.0,
),
const SizedBox(width: itemPadding),
Expanded(
child: Text(
widget.controller.updatedActivity.title,
style:
Theme.of(context).textTheme.bodyLarge,
),
),
if (!widget.controller.isEditing)
IconButton(
onPressed: _isBookmarked
? () => _removeBookmark()
: () => _addBookmark(
widget.controller.updatedActivity,
),
],
),
const SizedBox(height: itemPadding),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.groups, size: 24.0),
const SizedBox(width: itemPadding),
Expanded(
child: Text(
L10n.of(context)
.minimumActivityParticipants(
widget.controller.updatedActivity.req
.numberOfParticipants,
),
style:
Theme.of(context).textTheme.bodyLarge,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.radar, size: 24.0),
const SizedBox(width: itemPadding),
Expanded(
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
L10n.of(context).numberOfActivities,
style: Theme.of(context)
.textTheme
.bodyLarge,
),
icon: Icon(
_isBookmarked
? Icons.save
: Icons.save_outlined,
),
NumberCounter(
count: widget.controller.numActivities,
update:
widget.controller.setNumActivities,
min: 1,
max: 5,
),
],
),
),
],
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.target,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller: widget.controller
.learningObjectivesController,
decoration: InputDecoration(
labelText: l10n.learningObjectiveLabel,
),
maxLines: null,
)
: Text(
widget.controller.updatedActivity
.learningObjective,
style:
Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.steps_rounded,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller: widget
.controller.instructionsController,
decoration: InputDecoration(
labelText: l10n.instructions,
),
maxLines: null,
)
: Text(
widget.controller.updatedActivity
.instructions,
style:
Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Icons.school_outlined,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? LanguageLevelDropdown(
initialLevel:
widget.controller.languageLevel,
onChanged:
widget.controller.setLanguageLevel,
)
: Text(
widget.controller.updatedActivity.req
.cefrLevel
.title(context),
style:
Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
if (widget.controller.vocab.isNotEmpty) ...[
Row(
children: [
Icon(
Symbols.dictionary,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: List<Widget>.generate(
widget.controller.vocab.length,
(int index) {
return widget.controller.isEditing
? Chip(
label: Text(
widget
.controller.vocab[index].lemma,
),
onDeleted: () => widget.controller
.removeVocab(index),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
)
: Chip(
label: Text(
widget
.controller.vocab[index].lemma,
),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
);
}).toList(),
),
),
],
),
],
if (widget.controller.isEditing) ...[
const SizedBox(height: itemPadding),
Padding(
padding: const EdgeInsets.only(top: itemPadding),
child: Row(
children: [
Expanded(
child: TextField(
controller: widget.controller.vocabController,
decoration: InputDecoration(
labelText: l10n.addVocabulary,
),
onSubmitted: (value) {
widget.controller.addVocab();
},
const SizedBox(height: itemPadding),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primaryContainer,
foregroundColor:
theme.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: widget.controller.addVocab,
onPressed: _onLaunch,
child: Row(
children: [
const Icon(Icons.send_outlined),
Expanded(
child: Text(
L10n.of(context).launchToSpace,
textAlign: TextAlign.center,
),
),
],
),
),
]
: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.event_note_outlined),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller:
widget.controller.titleController,
decoration: InputDecoration(
labelText:
L10n.of(context).activityTitle,
),
maxLines: null,
)
: Text(
widget
.controller.updatedActivity.title,
style: Theme.of(context)
.textTheme
.bodyLarge,
),
),
if (!widget.controller.isEditing)
IconButton(
onPressed: _isBookmarked
? () => _removeBookmark()
: () => _addBookmark(
widget.controller.updatedActivity,
),
icon: Icon(
_isBookmarked
? Icons.save
: Icons.save_outlined,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.target,
color:
Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller: widget.controller
.learningObjectivesController,
decoration: InputDecoration(
labelText:
l10n.learningObjectiveLabel,
),
maxLines: null,
)
: Text(
widget.controller.updatedActivity
.learningObjective,
style: Theme.of(context)
.textTheme
.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.steps_rounded,
color:
Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller: widget.controller
.instructionsController,
decoration: InputDecoration(
labelText: l10n.instructions,
),
maxLines: null,
)
: Text(
widget.controller.updatedActivity
.instructions,
style: Theme.of(context)
.textTheme
.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Icons.school_outlined,
color:
Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? LanguageLevelDropdown(
initialLevel:
widget.controller.languageLevel,
onChanged: widget
.controller.setLanguageLevel,
)
: Text(
widget.controller.updatedActivity.req
.cefrLevel
.title(context),
style: Theme.of(context)
.textTheme
.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
if (widget.controller.vocab.isNotEmpty) ...[
Row(
children: [
Icon(
Symbols.dictionary,
color:
Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: List<Widget>.generate(
widget.controller.vocab.length,
(int index) {
return widget.controller.isEditing
? Chip(
label: Text(
widget.controller.vocab[index]
.lemma,
),
onDeleted: () => widget
.controller
.removeVocab(index),
backgroundColor:
Colors.transparent,
visualDensity:
VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
)
: Chip(
label: Text(
widget.controller.vocab[index]
.lemma,
),
backgroundColor:
Colors.transparent,
visualDensity:
VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
);
}).toList(),
),
),
],
),
],
),
),
],
const SizedBox(height: itemPadding),
widget.controller.isEditing
? Row(
spacing: 12.0,
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primaryContainer,
foregroundColor:
theme.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: widget.controller.saveEdits,
child: Row(
children: [
const Icon(Icons.save),
Expanded(
child: Text(
L10n.of(context).save,
textAlign: TextAlign.center,
),
),
],
),
),
),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primaryContainer,
foregroundColor:
theme.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: widget.controller.clearEdits,
child: Row(
children: [
const Icon(Icons.cancel),
Expanded(
child: Text(
L10n.of(context).cancel,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
)
: Column(
spacing: 12.0,
children: [
Row(
spacing: 12.0,
if (widget.controller.isEditing) ...[
const SizedBox(height: itemPadding),
Padding(
padding:
const EdgeInsets.only(top: itemPadding),
child: Row(
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme
.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
child: TextField(
controller:
widget.controller.vocabController,
decoration: InputDecoration(
labelText: l10n.addVocabulary,
),
child: Row(
children: [
const Icon(Icons.edit),
Expanded(
child: Text(
L10n.of(context).edit,
textAlign: TextAlign.center,
),
),
],
),
onPressed: () =>
widget.controller.setEditing(true),
onSubmitted: (value) {
widget.controller.addVocab();
},
),
),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme
.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: widget.regenerate,
child: Row(
children: [
const Icon(Icons.lightbulb_outline),
Expanded(
child: Text(
L10n.of(context).regenerate,
textAlign: TextAlign.center,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: widget.controller.addVocab,
),
],
),
Row(
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme
.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: _onLaunch,
child: Row(
children: [
const Icon(Icons.send),
Expanded(
child: Text(
L10n.of(context)
.launchActivityButton,
textAlign: TextAlign.center,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
spacing: 12.0,
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: widget.controller.saveEdits,
child: Row(
children: [
const Icon(Icons.save),
Expanded(
child: Text(
L10n.of(context).save,
textAlign: TextAlign.center,
),
),
],
),
),
],
),
],
),
],
),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: widget.controller.clearEdits,
child: Row(
children: [
const Icon(Icons.cancel),
Expanded(
child: Text(
L10n.of(context).cancel,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
),
] else
Column(
spacing: 12.0,
children: [
Row(
spacing: 12.0,
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme
.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed:
widget.controller.startEditing,
child: Row(
children: [
const Icon(Icons.edit),
Expanded(
child: Text(
L10n.of(context).edit,
textAlign: TextAlign.center,
),
),
],
),
),
),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme
.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: widget.regenerate,
child: Row(
children: [
const Icon(
Icons.lightbulb_outline,
),
Expanded(
child: Text(
L10n.of(context).regenerate,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
),
Row(
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme
.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
),
onPressed: () {
widget.controller.setLaunchState(
ActivityLaunchState.launching,
);
},
child: Row(
children: [
const Icon(Icons.save_outlined),
Expanded(
child: Text(
L10n.of(context)
.saveAndLaunch,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
),
],
),
],
),
),
],

View file

@ -1,5 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Visibility;
import 'package:collection/collection.dart';
import 'package:http/http.dart' as http;
@ -8,18 +8,27 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.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';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/utils/client_download_content_extension.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum ActivityLaunchState {
base,
editing,
launching,
}
class ActivityPlannerBuilder extends StatefulWidget {
final ActivityPlanModel initialActivity;
final String? initialFilename;
final Room? room;
final Room room;
final Widget Function(ActivityPlannerBuilderState) builder;
@ -27,7 +36,7 @@ class ActivityPlannerBuilder extends StatefulWidget {
super.key,
required this.initialActivity,
this.initialFilename,
this.room,
required this.room,
required this.builder,
});
@ -36,11 +45,13 @@ class ActivityPlannerBuilder extends StatefulWidget {
}
class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
bool isEditing = false;
ActivityLaunchState launchState = ActivityLaunchState.base;
Uint8List? avatar;
String? imageURL;
String? filename;
int numActivities = 1;
final TextEditingController titleController = TextEditingController();
final TextEditingController instructionsController = TextEditingController();
final TextEditingController vocabController = TextEditingController();
@ -69,7 +80,10 @@ class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
super.dispose();
}
Room? get room => widget.room;
Room get room => widget.room;
bool get isEditing => launchState == ActivityLaunchState.editing;
bool get isLaunching => launchState == ActivityLaunchState.launching;
ActivityPlanRequest get updatedRequest {
final int participants = int.tryParse(participantsController.text.trim()) ??
@ -135,8 +149,10 @@ class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
if (mounted) setState(() {});
}
void setEditing(bool editting) {
isEditing = editting;
void startEditing() => setLaunchState(ActivityLaunchState.editing);
void setLaunchState(ActivityLaunchState state) {
launchState = state;
if (mounted) setState(() {});
}
@ -178,6 +194,10 @@ class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
}
}
void setNumActivities(int count) {
if (mounted) setState(() => numActivities = count);
}
Future<void> _setAvatarByURL(String url) async {
try {
if (avatar == null) {
@ -223,7 +243,7 @@ class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
Future<void> saveEdits() async {
if (!formKey.currentState!.validate()) return;
await updateImageURL();
setEditing(false);
setLaunchState(ActivityLaunchState.base);
await BookmarkedActivitiesRepo.remove(widget.initialActivity.bookmarkId);
await BookmarkedActivitiesRepo.save(updatedActivity);
@ -232,20 +252,87 @@ class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
Future<void> clearEdits() async {
await resetActivity();
if (mounted) {
setState(() {
isEditing = false;
});
setLaunchState(ActivityLaunchState.base);
}
Future<void> launchToSpace() async {
final List<String> activityRoomIDs = [];
try {
await Future.wait(
List.generate(numActivities, (i) async {
final id = await _launchActivityRoom(i);
activityRoomIDs.add(id);
}),
);
} catch (e) {
_cleanupFailedLaunch(activityRoomIDs);
rethrow;
}
}
Future<void> launchToRoom() async {
if (room == null || room!.isSpace) return;
return room?.sendActivityPlan(
Future<String> _launchActivityRoom(int index) async {
await updateImageURL();
final roomID = await Matrix.of(context).client.createGroupChat(
visibility: Visibility.private,
groupName: "${updatedActivity.title} ${index + 1}",
initialState: [
if (imageURL != null)
StateEvent(
type: EventTypes.RoomAvatar,
content: {'url': imageURL},
),
RoomDefaults.defaultPowerLevels(
Matrix.of(context).client.userID!,
),
await Matrix.of(context).client.pangeaJoinRules(
'knock_restricted',
allow: [
{
"type": "m.room_membership",
"room_id": room.id,
}
],
),
],
enableEncryption: false,
);
Room? activityRoom = room.client.getRoomById(roomID);
if (activityRoom == null) {
await room.client.waitForRoomInSync(roomID);
activityRoom = room.client.getRoomById(roomID);
if (activityRoom == null) {
throw Exception("Failed to create activity room");
}
}
await room.addToSpace(activityRoom.id);
if (activityRoom.pangeaSpaceParents.isEmpty) {
await room.client.waitForRoomInSync(activityRoom.id);
}
await activityRoom.sendActivityPlan(
updatedActivity,
avatar: avatar,
filename: filename,
);
return activityRoom.id;
}
Future<void> _cleanupFailedLaunch(List<String> roomIds) async {
final futures = roomIds.map((id) async {
final room = Matrix.of(context).client.getRoomById(id);
if (room == null) return;
try {
await room.leave();
} catch (e) {
debugPrint("Failed to leave room $id: $e");
}
});
await Future.wait(futures);
}
@override

View file

@ -11,6 +11,7 @@ import 'package:fluffychat/pangea/activity_planner/bookmarked_activity_list.dart
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart';
import 'package:fluffychat/pangea/common/widgets/customized_svg.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum PageMode {
@ -38,23 +39,27 @@ class ActivityPlannerPageState extends State<ActivityPlannerPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget body = const SizedBox();
Widget? body;
switch (pageMode) {
case PageMode.savedActivities:
body = BookmarkedActivitiesList(
room: room,
controller: this,
);
if (room != null) {
body = BookmarkedActivitiesList(
room: room!,
controller: this,
);
}
break;
case PageMode.featuredActivities:
body = Expanded(
child: SingleChildScrollView(
child: ActivitySuggestionsArea(
scrollDirection: Axis.vertical,
room: room,
if (room != null) {
body = Expanded(
child: SingleChildScrollView(
child: ActivitySuggestionsArea(
scrollDirection: Axis.vertical,
room: room!,
),
),
),
);
);
}
break;
}
@ -130,7 +135,7 @@ class ActivityPlannerPageState extends State<ActivityPlannerPage> {
height: 24.0,
width: 24.0,
),
Text(L10n.of(context).createActivity),
Text(L10n.of(context).createActivityPlan),
],
),
selected: false,
@ -141,7 +146,10 @@ class ActivityPlannerPageState extends State<ActivityPlannerPage> {
],
),
),
body,
body ??
ErrorIndicator(
message: L10n.of(context).oopsSomethingWentWrong,
),
],
),
),

View file

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/widgets/avatar.dart';
class ActivityResultsCarousel extends StatelessWidget {
final ActivityRoleModel selectedRole;
final User user;
final ParticipantSummaryModel summary;
final VoidCallback? moveLeft;
final VoidCallback? moveRight;
const ActivityResultsCarousel({
super.key,
required this.selectedRole,
required this.moveLeft,
required this.moveRight,
required this.user,
required this.summary,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: moveLeft,
),
Padding(
padding: const EdgeInsets.all(12.0),
child: PressableButton(
onPressed: null,
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),
),
width: isColumnMode ? 225.0 : 175.0,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.center,
child: Avatar(
size: isColumnMode ? 60.0 : 40.0,
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
userId: selectedRole.userId,
),
),
const SizedBox(height: 4.0),
Text(
selectedRole.role != null
? "${selectedRole.role!} | ${selectedRole.userId.localpart}"
: "${selectedRole.userId.localpart}",
style: TextStyle(fontSize: isColumnMode ? 16.0 : 12.0),
),
const SizedBox(height: 10.0),
Text(
summary.feedback,
style: TextStyle(fontSize: isColumnMode ? 12.0 : 8.0),
),
const SizedBox(height: 10.0),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: [
Container(
padding: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.school, size: 12.0),
Text(
summary.cefrLevel,
style: TextStyle(
fontSize: isColumnMode ? 12.0 : 8.0,
),
),
],
),
),
...summary.superlatives.map(
(sup) => Container(
padding: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
sup,
style: TextStyle(
fontSize: isColumnMode ? 12.0 : 8.0,
),
),
),
),
],
),
],
),
),
),
),
),
IconButton(
icon: const Icon(Icons.arrow_forward_ios),
onPressed: moveRight,
),
],
);
}
}

View file

@ -0,0 +1,53 @@
class ActivityRoleModel {
final String userId;
final String? role;
DateTime? finishedAt;
DateTime? archivedAt;
ActivityRoleModel({
required this.userId,
this.role,
this.finishedAt,
this.archivedAt,
});
bool get isFinished => finishedAt != null;
bool get isArchived => archivedAt != null;
factory ActivityRoleModel.fromJson(Map<String, dynamic> json) {
return ActivityRoleModel(
userId: json['userId'],
role: json['role'],
finishedAt: json['finishedAt'] != null
? DateTime.parse(json['finishedAt'])
: null,
archivedAt: json['archivedAt'] != null
? DateTime.parse(json['archivedAt'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'userId': userId,
'role': role,
'finishedAt': finishedAt?.toIso8601String(),
'archivedAt': archivedAt?.toIso8601String(),
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ActivityRoleModel &&
other.userId == userId &&
other.role == role &&
other.finishedAt == finishedAt;
}
@override
int get hashCode =>
userId.hashCode ^ role.hashCode ^ (finishedAt?.hashCode ?? 0);
}

View file

@ -0,0 +1,220 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_repo.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
extension ActivityRoomExtension on Room {
Future<void> sendActivityPlan(
ActivityPlanModel activity, {
Uint8List? avatar,
String? filename,
}) async {
BookmarkedActivitiesRepo.save(activity);
if (canChangeStateEvent(PangeaEventTypes.activityPlan)) {
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityPlan,
"",
activity.toJson(),
);
}
}
Future<void> setActivityRole({
String? role,
}) async {
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
client.userID!,
ActivityRoleModel(
userId: client.userID!,
role: role,
).toJson(),
);
}
Future<void> finishActivity() async {
final role = activityRole(client.userID!);
if (role == null) return;
role.finishedAt = DateTime.now();
final syncFuture = client.waitForRoomInSync(id);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
client.userID!,
role.toJson(),
);
await syncFuture;
}
Future<void> setActivitySummary(
ActivitySummaryResponseModel summary,
) async {
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activitySummary,
"",
summary.toJson(),
);
}
Future<void> fetchSummaries() async {
if (activitySummary != null) return;
final events = await getAllEvents(this);
final List<ActivitySummaryResultsMessage> messages = [];
for (final event in events) {
if (event.type != EventTypes.Message ||
event.messageType != MessageTypes.Text) {
continue;
}
final timeline = this.timeline ?? await getTimeline();
final pangeaMessage = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: client.userID == event.senderId,
);
final activityMessage = ActivitySummaryResultsMessage(
userId: event.senderId,
sent: pangeaMessage.originalSent?.text ?? event.body,
written: pangeaMessage.originalWrittenContent,
time: event.originServerTs,
tool: [
if (pangeaMessage.originalSent?.choreo?.includedIT == true) "it",
if (pangeaMessage.originalSent?.choreo?.includedIGC == true) "igc",
],
);
messages.add(activityMessage);
}
final resp = await ActivitySummaryRepo.get(
ActivitySummaryRequestModel(
activity: activityPlan!,
activityResults: messages,
contentFeedback: [],
),
);
await setActivitySummary(resp);
}
Future<void> archiveActivity() async {
final role = activityRole(client.userID!);
if (role == null) return;
role.archivedAt = DateTime.now();
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
client.userID!,
role.toJson(),
);
}
ActivityPlanModel? get activityPlan {
final stateEvent = getState(PangeaEventTypes.activityPlan);
if (stateEvent == null) return null;
try {
return ActivityPlanModel.fromJson(stateEvent.content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"stateEvent": stateEvent.content,
},
);
return null;
}
}
ActivityRoleModel? activityRole(String userId) {
final stateEvent = getState(PangeaEventTypes.activityRole, userId);
if (stateEvent == null) return null;
try {
return ActivityRoleModel.fromJson(stateEvent.content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"userId": userId,
"stateEvent": stateEvent.content,
},
);
return null;
}
}
ActivitySummaryResponseModel? get activitySummary {
final stateEvent = getState(PangeaEventTypes.activitySummary);
if (stateEvent == null) return null;
try {
return ActivitySummaryResponseModel.fromJson(stateEvent.content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"stateEvent": stateEvent.content,
},
);
return null;
}
}
List<StrippedStateEvent> get _activityRoleEvents {
return states[PangeaEventTypes.activityRole]?.values.toList() ?? [];
}
List<ActivityRoleModel> get activityRoles {
return _activityRoleEvents
.map((r) => ActivityRoleModel.fromJson(r.content))
.toList();
}
bool get hasJoinedActivity {
return activityPlan == null || activityRole(client.userID!) != null;
}
bool get hasFinishedActivity {
final role = activityRole(client.userID!);
return role != null && role.isFinished;
}
bool get activityIsFinished {
return activityRoles.isNotEmpty && activityRoles.every((r) => r.isFinished);
}
int? get numberOfParticipants {
return activityPlan?.req.numberOfParticipants;
}
int get remainingRoles {
if (numberOfParticipants == null) return 0;
return max(0, numberOfParticipants! - activityRoles.length);
}
}

View file

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_planner/activity_finished_status_message.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_planner/activity_unfinished_status_message.dart';
class ActivityStatusMessage extends StatefulWidget {
final Room room;
const ActivityStatusMessage({
super.key,
required this.room,
});
@override
ActivityStatusMessageState createState() => ActivityStatusMessageState();
}
class ActivityStatusMessageState extends State<ActivityStatusMessage> {
@override
void initState() {
super.initState();
if (widget.room.activityIsFinished && widget.room.activitySummary == null) {
widget.room.fetchSummaries().then((_) {
if (mounted) setState(() {});
});
}
}
@override
Widget build(BuildContext context) {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Material(
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
child: !widget.room.hasJoinedActivity ||
widget.room.activityIsFinished
? Padding(
padding: EdgeInsets.only(
bottom: FluffyThemes.isColumnMode(context) ? 32.0 : 16.0,
left: 16.0,
right: 16.0,
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: SingleChildScrollView(
child: widget.room.activityIsFinished
? ActivityFinishedStatusMessage(room: widget.room)
: ActivityUnfinishedStatusMessage(room: widget.room),
),
),
)
: const SizedBox.shrink(),
),
),
);
}
}

View file

@ -0,0 +1,96 @@
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/pangea/activity_planner/activity_participant_indicator.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class ActivityUnfinishedStatusMessage extends StatefulWidget {
final Room room;
const ActivityUnfinishedStatusMessage({
super.key,
required this.room,
});
@override
ActivityUnfinishedStatusMessageState createState() =>
ActivityUnfinishedStatusMessageState();
}
class ActivityUnfinishedStatusMessageState
extends State<ActivityUnfinishedStatusMessage> {
int? _selectedRole;
void _selectRole(int role) {
if (_selectedRole == role) return;
if (mounted) setState(() => _selectedRole = role);
}
@override
Widget build(BuildContext context) {
final unassignedRoles = widget.room.remainingRoles;
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
return Column(
children: [
if (unassignedRoles > 0)
Wrap(
spacing: 12.0,
runSpacing: 12.0,
children: List.generate(unassignedRoles, (index) {
return ActivityParticipantIndicator(
selected: _selectedRole == index,
onTap: () => _selectRole(index),
);
}),
),
const SizedBox(height: 16.0),
Text(
unassignedRoles > 0
? L10n.of(context).unjoinedActivityMessage
: L10n.of(context).fullActivityMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
const SizedBox(height: 16.0),
if (unassignedRoles > 0)
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
foregroundColor: theme.colorScheme.onPrimaryContainer,
backgroundColor: theme.colorScheme.primaryContainer,
),
onPressed: _selectedRole != null
? () {
showFutureLoadingDialog(
context: context,
future: () => widget.room.setActivityRole(),
);
}
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).confirmRole,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
],
),
),
],
);
}
}

View file

@ -12,7 +12,7 @@ import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart';
class BookmarkedActivitiesList extends StatefulWidget {
final Room? room;
final Room room;
final ActivityPlannerPageState controller;

View file

@ -1,628 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.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';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.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/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.dart';
import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart';
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class ActivityRoomSelection extends StatefulWidget {
final ActivityPlannerBuilderState controller;
final Widget backButton;
const ActivityRoomSelection({
super.key,
required this.controller,
required this.backButton,
});
@override
State<ActivityRoomSelection> createState() => ActivityRoomSelectionState();
}
class ActivityRoomSelectionState extends State<ActivityRoomSelection> {
final TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
bool _loading = false;
bool _complete = false;
bool _hasBotDM = true;
List<Room> _launchableRooms = [];
final List<String> _selectedRooms = [];
@override
void initState() {
super.initState();
_launchableRooms = Matrix.of(context)
.client
.rooms
.where((room) {
return room.canSendDefaultStates &&
!room.isSpace &&
!room.isAnalyticsRoom;
})
.toList()
.sorted((a, b) {
final aIsBotDM = a.directChatMatrixID == BotName.byEnvironment;
final bIsBotDM = b.directChatMatrixID == BotName.byEnvironment;
if (aIsBotDM && !bIsBotDM) return -1;
if (!aIsBotDM && bIsBotDM) return 1;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
final room = widget.controller.room;
if (room != null && room.isSpace) {
_launchableRooms = _launchableRooms.where((r) {
return room.spaceChildren.any((child) => child.roomId == r.id);
}).toList();
}
_hasBotDM = Matrix.of(context).client.rooms.any((room) {
if (room.isDirectChat &&
room.directChatMatrixID == BotName.byEnvironment) {
return true;
}
if (room.botOptions?.mode == BotMode.directChat) {
return true;
}
return false;
});
}
@override
void dispose() {
searchController.dispose();
searchFocusNode.dispose();
super.dispose();
}
List<Room> get _filteredRooms {
final searchText = searchController.text.toLowerCase();
return _launchableRooms.where((room) {
return room.name.toLowerCase().contains(searchText);
}).toList();
}
void _toggleRoomSelection(String roomId) {
_selectedRooms.contains(roomId)
? _selectedRooms.remove(roomId)
: _selectedRooms.add(roomId);
if (_selectedRooms.contains(roomId)) {
_complete = false;
}
setState(() {});
}
Map<String, Room> get _spaceDelegateCandidates {
final spaces = Matrix.of(context).client.rooms.where((r) => r.isSpace);
final candidates = <String, Room>{};
for (final space in spaces) {
for (final spaceChild in space.spaceChildren) {
final roomId = spaceChild.roomId;
if (roomId == null) continue;
candidates[roomId] = space;
}
}
return candidates;
}
final Map<String, int> _launchStatus = {};
Future<void> _sendActivityPlan(Room room) async {
try {
setState(() => _launchStatus[room.id] = 0);
await room.sendActivityPlan(
widget.controller.updatedActivity,
avatar: widget.controller.avatar,
filename: widget.controller.filename,
);
_launchStatus[room.id] = 1;
} catch (e, s) {
_launchStatus[room.id] = -1;
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": room.id,
"activity": widget.controller.updatedActivity.toJson(),
"filename": widget.controller.filename,
"avatarURL": widget.controller.imageURL,
},
);
} finally {
if (mounted) {
setState(() {});
}
}
}
Future<String?> _launchBotDM() async {
try {
setState(() => _launchStatus["placeholder"] = 0);
Uri? avatarUrl;
final imageUrl = widget.controller.imageURL ??
widget.controller.updatedActivity.imageURL;
Uint8List? avatar = widget.controller.avatar;
if (avatar != null) {
avatarUrl = await Matrix.of(context).client.uploadContent(
widget.controller.avatar!,
);
} else if (imageUrl != null) {
final Response response = await http.get(Uri.parse(imageUrl));
avatar = response.bodyBytes;
avatarUrl = await Matrix.of(context).client.uploadContent(
avatar,
);
}
// avatar == null ? null : await client.uploadContent(avatar);
final roomId = await Matrix.of(context).client.createRoom(
name: widget.controller.updatedActivity.title,
invite: [BotName.byEnvironment],
isDirect: true,
preset: CreateRoomPreset.trustedPrivateChat,
initialState: [
BotOptionsModel(mode: BotMode.directChat).toStateEvent,
RoomDefaults.defaultPowerLevels(
Matrix.of(context).client.userID!,
),
if (avatar != null && avatarUrl != null)
StateEvent(
type: EventTypes.RoomAvatar,
content: {'url': avatarUrl.toString()},
),
],
);
Room? room = Matrix.of(context).client.getRoomById(roomId);
if (room == null) {
await Matrix.of(context).client.waitForRoomInSync(
roomId,
join: true,
);
room = Matrix.of(context).client.getRoomById(roomId);
if (room == null) {
throw Exception("Room not found");
}
await room.sendActivityPlan(
widget.controller.updatedActivity,
avatar: widget.controller.avatar,
filename: widget.controller.filename,
);
}
_launchStatus["placeholder"] = 1;
return roomId;
} catch (e, s) {
_launchStatus["placeholder"] = -1;
ErrorHandler.logError(
e: e,
s: s,
data: {
"activity": widget.controller.updatedActivity.toJson(),
"filename": widget.controller.filename,
"avatarURL": widget.controller.imageURL,
},
);
} finally {
if (mounted) {
setState(() {});
}
}
return null;
}
Future<void> _launch() async {
setState(() => _loading = true);
try {
final List<Future> futures = [];
for (final roomId in _selectedRooms) {
if (_launchStatus[roomId] == 1) {
continue;
}
final Room? room = _launchableRooms.firstWhereOrNull(
(r) => r.id == roomId,
);
if (room == null) {
if (roomId == 'placeholder') futures.add(_launchBotDM());
} else {
futures.add(_sendActivityPlan(room));
}
}
final resp = await Future.wait(futures);
_complete = true;
if (!mounted) return;
if (_selectedRooms.length == 1 &&
_launchStatus[_selectedRooms.first] == 1) {
if (_selectedRooms.first == 'placeholder' && resp.first != null) {
context.go("/rooms/${resp.first}");
Navigator.of(context).pop();
} else if (_selectedRooms.first != 'placeholder') {
context.go('/rooms/${_selectedRooms.first}');
Navigator.of(context).pop();
}
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"activity": widget.controller.updatedActivity.toJson(),
"filename": widget.controller.filename,
"avatarURL": widget.controller.imageURL,
},
);
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
String _tooltip(String roomId) {
final status = _launchStatus[roomId];
if (status == 0) {
return "Sending...";
} else if (status == 1) {
return "Go to chat";
} else if (status == -1) {
return "Failed to send";
}
return "";
}
void _onTap(Room room) {
final status = _launchStatus[room.id];
if (status == 0) {
return;
} else if (status == 1) {
context.go('/rooms/${room.id}');
Navigator.of(context).pop();
} else if (status == -1) {
return;
}
debugPrint("Toggling room selection for ${room.id}");
_toggleRoomSelection(room.id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).selectChats),
leading: widget.backButton,
),
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Column(
spacing: 16.0,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
controller: searchController,
focusNode: searchFocusNode,
textInputAction: TextInputAction.search,
onChanged: (text) => setState(() {}),
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
hintText: L10n.of(context).searchChats,
hintStyle: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
suffixIcon: searchController.text.isNotEmpty
? IconButton(
tooltip: L10n.of(context).cancel,
icon: const Icon(Icons.close_outlined),
onPressed: () {
setState(() {
searchController.clear();
searchFocusNode.unfocus();
});
},
color: theme.colorScheme.onPrimaryContainer,
)
: IconButton(
onPressed: () => searchFocusNode.requestFocus(),
icon: Icon(
Icons.search_outlined,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
),
),
Expanded(
child: ListView.builder(
itemCount: _filteredRooms.length + (_hasBotDM ? 0 : 1),
itemBuilder: (context, index) {
if (!_hasBotDM && index == 0) {
return ChatActivityPlaceholder(
activity: widget.controller.updatedActivity,
selected: _selectedRooms.contains("placeholder"),
onTap: () {
_toggleRoomSelection("placeholder");
},
tooltip: _tooltip("placeholder"),
status: _launchStatus["placeholder"],
avatar: widget.controller.avatar,
);
}
if (!_hasBotDM) index--;
final room = _filteredRooms[index];
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
final space = _spaceDelegateCandidates[room.id];
return Tooltip(
message: _tooltip(room.id),
child: ListTile(
title: Text(displayname),
leading: SizedBox(
width: Avatar.defaultSize,
height: Avatar.defaultSize,
child: Stack(
children: [
if (space != null)
Positioned(
top: 0,
left: 0,
child: Avatar(
border: BorderSide(
width: 2,
color: theme.colorScheme.surface,
),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 4,
),
mxContent: space.avatar,
size: Avatar.defaultSize * 0.75,
name: space.getLocalizedDisplayname(),
),
),
Positioned(
bottom: 0,
right: 0,
child: Avatar(
border: space == null
? room.isSpace
? BorderSide(
width: 1,
color: theme.dividerColor,
)
: null
: BorderSide(
width: 2,
color: theme.colorScheme.surface,
),
mxContent: room.avatar,
size: Avatar.defaultSize * 0.75,
name: displayname,
presenceUserId: room.directChatMatrixID,
),
),
],
),
),
trailing: Container(
width: 30.0,
height: 30.0,
alignment: Alignment.center,
child: Builder(
builder: (context) {
final status = _launchStatus[room.id];
if (status == 0) {
return const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
);
} else if (status == 1) {
return const Icon(
Icons.check_circle_outline,
color: AppConfig.success,
);
} else if (status == -1) {
return Icon(
Icons.error_outline,
color: theme.colorScheme.error,
);
}
return Checkbox(
value: _selectedRooms.contains(room.id),
onChanged: (_) => _onTap(room),
);
},
),
),
onTap: () => _onTap(room),
),
);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _complete
? Padding(
padding: const EdgeInsets.all(8.0),
child: Text(L10n.of(context).selectChatToStart),
)
: ElevatedButton(
onPressed: _selectedRooms.isNotEmpty ? _launch : null,
style: ElevatedButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.all(6.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
disabledBackgroundColor: theme.colorScheme.primary,
disabledForegroundColor: theme.colorScheme.onPrimary,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_loading
? const Expanded(
child: SizedBox(
height: 10,
child: LinearProgressIndicator(),
),
)
: Text(
L10n.of(context).launchActivityToChats,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onPrimary,
),
),
],
),
),
),
],
),
),
);
}
}
class ChatActivityPlaceholder extends StatelessWidget {
final ActivityPlanModel activity;
final bool selected;
final VoidCallback onTap;
final String tooltip;
final Uint8List? avatar;
final int? status;
const ChatActivityPlaceholder({
required this.activity,
required this.selected,
required this.onTap,
required this.tooltip,
required this.status,
this.avatar,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const size = Avatar.defaultSize * 0.75;
return Tooltip(
message: tooltip,
child: ListTile(
title: Text(activity.title),
leading: SizedBox(
width: Avatar.defaultSize,
height: Avatar.defaultSize,
child: SizedBox(
width: size,
height: size,
child: Material(
color: theme.brightness == Brightness.light
? Colors.white
: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(size / 2),
side: BorderSide.none,
),
clipBehavior: Clip.hardEdge,
child: avatar != null
? Image.memory(avatar!)
: activity.imageURL != null
? activity.imageURL!.startsWith('mxc')
? MxcImage(
uri: Uri.parse(activity.imageURL!),
width: size,
height: size,
cacheKey: activity.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: activity.imageURL!,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) =>
const SizedBox(),
fit: BoxFit.cover,
)
: const SizedBox(),
),
),
),
trailing: Container(
width: 30.0,
height: 30.0,
alignment: Alignment.center,
child: Builder(
builder: (context) {
if (status == 0) {
return const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
);
} else if (status == 1) {
return const Icon(
Icons.check_circle_outline,
color: AppConfig.success,
);
} else if (status == -1) {
return Icon(
Icons.error_outline,
color: theme.colorScheme.error,
);
}
return Checkbox(
value: selected,
onChanged: (_) => onTap(),
);
},
),
),
onTap: onTap,
),
);
}
}

View file

@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
class ActivitySuggestionCardRow extends StatelessWidget {
final IconData icon;
final IconData? icon;
final Widget? leading;
final Widget child;
const ActivitySuggestionCardRow({
required this.icon,
required this.child,
this.icon,
this.leading,
super.key,
});
@ -15,12 +17,14 @@ class ActivitySuggestionCardRow extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
spacing: 8.0,
spacing: 12.0,
children: [
Icon(
icon,
size: 16.0,
),
if (leading != null) leading!,
if (icon != null)
Icon(
icon,
size: 24.0,
),
Expanded(child: child),
],
),

View file

@ -1,353 +0,0 @@
import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_planner/media_enum.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.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/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivitySuggestionCarousel extends StatefulWidget {
final Function(
ActivityPlanModel?,
Uint8List? avatar,
String? filename,
) onActivitySelected;
final ActivityPlanModel? selectedActivity;
final Uint8List? selectedActivityImage;
final bool enabled;
const ActivitySuggestionCarousel({
required this.onActivitySelected,
required this.selectedActivity,
required this.selectedActivityImage,
this.enabled = true,
super.key,
});
@override
ActivitySuggestionCarouselState createState() =>
ActivitySuggestionCarouselState();
}
class ActivitySuggestionCarouselState
extends State<ActivitySuggestionCarousel> {
bool _loading = true;
bool _closed = false;
String? _error;
double get _cardWidth => _isColumnMode ? 250.0 : 175.0;
double get _cardHeight => _isColumnMode ? 360.0 : 275.0;
ActivityPlanModel? _currentActivity;
final List<ActivityPlanModel> _activityItems = [];
@override
void initState() {
super.initState();
_setActivityItems();
}
@override
void didUpdateWidget(covariant ActivitySuggestionCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selectedActivity != oldWidget.selectedActivity &&
_currentIndex != null &&
widget.selectedActivity != null) {
final prevIndex = _currentIndex!;
setState(
() {
_activityItems[prevIndex] = widget.selectedActivity!;
_currentActivity = widget.selectedActivity;
},
);
}
}
Future<void> _setActivityItems() async {
try {
final ActivityPlanRequest request = ActivityPlanRequest(
topic: "",
mode: "",
objective: "",
media: MediaEnum.nan,
cefrLevel: LanguageLevelTypeEnum.a1,
languageOfInstructions: LanguageKeys.defaultLanguage,
targetLanguage:
MatrixState.pangeaController.languageController.userL2?.langCode ??
LanguageKeys.defaultLanguage,
numberOfParticipants: 3,
count: 5,
);
final resp = await ActivitySearchRepo.get(request);
_activityItems.addAll(resp.activityPlans);
} catch (e) {
_error = e.toString();
} finally {
_loading = false;
_currentActivity =
_activityItems.isNotEmpty ? _activityItems.first : null;
if (mounted) setState(() {});
}
}
bool get _isColumnMode => FluffyThemes.isColumnMode(context);
int? get _currentIndex {
if (_currentActivity == null) return null;
final index = _activityItems.indexOf(_currentActivity!);
return index == -1 ? null : index;
}
bool get _canMoveLeft =>
widget.enabled && _currentIndex != null && _currentIndex! > 0;
bool get _canMoveRight =>
widget.enabled &&
_currentIndex != null &&
_currentIndex! < _activityItems.length - 1;
void _moveLeft() {
if (!_canMoveLeft) return;
_setActivityByIndex(_currentIndex! - 1);
}
void _moveRight() {
if (!_canMoveRight) return;
_setActivityByIndex(_currentIndex! + 1);
}
void _setActivityByIndex(int index) {
if (index < 0 || index >= _activityItems.length) return;
setState(() {
_currentActivity = _activityItems[index];
});
}
void _close() {
widget.onActivitySelected(null, null, null);
setState(() {
_closed = true;
});
}
void _onReplaceActivity(ActivityPlanModel a) {
final index = _currentIndex;
if (index == null || index < 0 || index >= _activityItems.length) {
return;
}
_activityItems[index] = a;
setState(() => _currentActivity = a);
}
void _onClickCard() {
if (widget.selectedActivity == _currentActivity) {
widget.onActivitySelected(
null,
null,
null,
);
return;
}
showDialog(
context: context,
builder: (context) {
return ActivityPlannerBuilder(
initialActivity: _currentActivity!,
builder: (controller) {
return ActivitySuggestionDialog(
controller: controller,
buttonText: L10n.of(context).selectActivity,
replaceActivity: _onReplaceActivity,
onLaunch: () => widget.onActivitySelected(
controller.updatedActivity,
controller.avatar,
controller.filename,
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedSize(
duration: FluffyThemes.animationDuration,
child: AnimatedOpacity(
duration: FluffyThemes.animationDuration,
opacity: widget.enabled ? 1.0 : 0.5,
child: _closed
? const SizedBox.shrink()
: Container(
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 4.0,
),
child: Column(
spacing: 16.0,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
L10n.of(context).newChatActivityTitle,
style: theme.textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.enabled ? _close : null,
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(L10n.of(context).newChatActivityDesc),
),
Row(
spacing: _isColumnMode ? 16.0 : 4.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
MouseRegion(
cursor: _canMoveLeft
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: _canMoveLeft ? _moveLeft : null,
child: Icon(
Icons.chevron_left_outlined,
size: 32.0,
color: _canMoveLeft ? null : theme.disabledColor,
),
),
),
Container(
constraints:
BoxConstraints(maxHeight: _cardHeight + 12.0),
child: _error != null ||
(_currentActivity == null && !_loading)
? const SizedBox.shrink()
: _loading
? Shimmer.fromColors(
baseColor: theme.colorScheme.primary
.withAlpha(50),
highlightColor: theme.colorScheme.primary
.withAlpha(150),
child: Container(
height: _cardHeight,
width: _cardWidth,
decoration: BoxDecoration(
color: theme
.colorScheme.surfaceContainer,
borderRadius:
BorderRadius.circular(24.0),
),
),
)
: ActivitySuggestionCard(
selected: widget.selectedActivity ==
_currentActivity,
activity: _currentActivity!,
onPressed:
widget.enabled ? _onClickCard : null,
width: _cardWidth,
height: _cardHeight,
image: _currentActivity ==
widget.selectedActivity
? widget.selectedActivityImage
: null,
onChange: () {
if (mounted) setState(() {});
},
),
),
MouseRegion(
cursor: _canMoveRight
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: _canMoveRight ? _moveRight : null,
child: Icon(
Icons.chevron_right_outlined,
size: 32.0,
color: _canMoveRight ? null : theme.disabledColor,
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16.0,
children: _activityItems.mapIndexed((i, activity) {
final selected = activity == _currentActivity;
return InkWell(
enableFeedback: widget.enabled,
borderRadius: BorderRadius.circular(12.0),
onTap: widget.enabled
? () => _setActivityByIndex(i)
: null,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: selected ? 0.0 : 0.5,
sigmaY: selected ? 0.0 : 0.5,
),
child: Opacity(
opacity: selected ? 1.0 : 0.5,
child: ClipOval(
child: SizedBox.fromSize(
size: const Size.fromRadius(12.0),
child: activity.imageURL != null
? CachedNetworkImage(
imageUrl: activity.imageURL!,
errorWidget: (context, url, error) =>
const SizedBox(),
progressIndicatorBuilder:
(context, url, progress) {
return CircularProgressIndicator(
value: progress.progress,
);
},
)
: CircleAvatar(
backgroundColor:
theme.colorScheme.secondary,
radius: 12.0,
),
),
),
),
),
);
}).toList(),
),
],
),
),
),
);
}
}

View file

@ -1,41 +1,27 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_generation_repo.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/activity_suggestions/activity_room_selection.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_row.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog_content.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
enum _PageMode {
activity,
roomSelection,
}
class ActivitySuggestionDialog extends StatefulWidget {
final ActivityPlannerBuilderState controller;
final String buttonText;
final VoidCallback? onLaunch;
final Function(ActivityPlanModel)? replaceActivity;
const ActivitySuggestionDialog({
required this.controller,
required this.buttonText,
this.onLaunch,
this.replaceActivity,
super.key,
});
@ -46,40 +32,33 @@ class ActivitySuggestionDialog extends StatefulWidget {
}
class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
_PageMode _pageMode = _PageMode.activity;
bool _loading = false;
Object? _error;
String? _regenerateError;
String? _launchError;
double get _width => FluffyThemes.isColumnMode(context)
? 400.0
: MediaQuery.of(context).size.width;
Future<void> _launchActivity() async {
Future<void> launchActivity() async {
try {
if (!widget.controller.room.isSpace) {
throw Exception(
"Cannot launch activity in a non-space room",
);
}
setState(() {
_loading = true;
_error = null;
_regenerateError = null;
_launchError = null;
});
if (widget.onLaunch != null) {
widget.onLaunch!.call();
Navigator.of(context).pop();
} else if (widget.controller.room != null &&
!widget.controller.room!.isSpace) {
final resp = await showFutureLoadingDialog(
context: context,
future: widget.controller.launchToRoom,
);
if (!resp.isError) {
context.go("/rooms/${widget.controller.room!.id}");
Navigator.of(context).pop();
}
} else {
_setPageMode(_PageMode.roomSelection);
}
await widget.controller.launchToSpace();
context.go("/rooms?spaceId=${widget.controller.room.id}");
Navigator.of(context).pop();
} catch (e, s) {
_error = e;
_launchError = L10n.of(context).errorLaunchActivityMessage;
ErrorHandler.logError(
e: e,
s: s,
@ -96,16 +75,11 @@ class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
}
}
void _setPageMode(_PageMode mode) {
setState(() {
_pageMode = mode;
});
}
Future<void> _onRegenerate() async {
Future<void> onRegenerate() async {
setState(() {
_loading = true;
_error = null;
_regenerateError = null;
_launchError = null;
});
try {
@ -121,7 +95,7 @@ class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
widget.replaceActivity?.call(plan);
await widget.controller.overrideActivity(plan);
} catch (e, s) {
_error = e;
_regenerateError = L10n.of(context).errorRegenerateActivityMessage;
ErrorHandler.logError(
e: e,
s: s,
@ -142,22 +116,28 @@ class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
void _resetActivity() {
widget.controller.resetActivity();
setState(() {
_pageMode = _PageMode.activity;
_loading = false;
_error = null;
_regenerateError = null;
_launchError = null;
});
}
ButtonStyle get buttonStyle => ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
);
double get width => FluffyThemes.isColumnMode(context)
? 400.0
: MediaQuery.of(context).size.width;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final buttonStyle = ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
),
);
final buttonStyle = this.buttonStyle;
final body = Stack(
alignment: Alignment.topCenter,
@ -168,23 +148,22 @@ class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
),
child: Builder(
builder: (context) {
if (_pageMode == _PageMode.activity) {
if (_error != null) {
return Center(
child: Column(
spacing: 16.0,
mainAxisSize: MainAxisSize.min,
children: [
ErrorIndicator(
message:
L10n.of(context).errorRegenerateActivityMessage,
),
if (_regenerateError != null || _launchError != null) {
return Center(
child: Column(
spacing: 16.0,
mainAxisSize: MainAxisSize.min,
children: [
ErrorIndicator(
message: _regenerateError ?? _launchError!,
),
if (_regenerateError != null)
Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: _onRegenerate,
onPressed: onRegenerate,
style: buttonStyle,
child: Text(L10n.of(context).tryAgain),
),
@ -194,552 +173,46 @@ class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
child: Text(L10n.of(context).reset),
),
],
)
else
ElevatedButton(
onPressed: launchActivity,
style: buttonStyle,
child: Text(L10n.of(context).tryAgain),
),
],
),
);
}
if (_loading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
return Form(
key: widget.controller.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: SingleChildScrollView(
child: Column(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.bottomCenter,
children: [
Container(
padding: const EdgeInsets.all(24.0),
width: (_width / 2) + 42.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: widget.controller.avatar != null
? Image.memory(
widget.controller.avatar!,
fit: BoxFit.cover,
)
: widget.controller.updatedActivity
.imageURL !=
null
? widget.controller
.updatedActivity.imageURL!
.startsWith("mxc")
? MxcImage(
uri: Uri.parse(
widget
.controller
.updatedActivity
.imageURL!,
),
width: _width / 2,
height: 200,
cacheKey: widget
.controller
.updatedActivity
.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: widget
.controller
.updatedActivity
.imageURL!,
fit: BoxFit.cover,
placeholder: (
context,
url,
) =>
const Center(
child:
CircularProgressIndicator(),
),
errorWidget: (
context,
url,
error,
) =>
const SizedBox(),
)
: null,
),
),
if (widget.controller.isEditing)
InkWell(
borderRadius: BorderRadius.circular(90),
onTap: widget.controller.selectAvatar,
child: CircleAvatar(
backgroundColor: Theme.of(context)
.colorScheme
.secondary,
radius: 20.0,
child: Icon(
Icons.add_a_photo_outlined,
size: 20.0,
color: Theme.of(context)
.colorScheme
.onSecondary,
),
),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Column(
children: [
if (widget.controller.isEditing)
ActivitySuggestionCardRow(
icon: Icons.event_note_outlined,
child: TextFormField(
controller:
widget.controller.titleController,
decoration: InputDecoration(
labelText:
L10n.of(context).activityTitle,
),
maxLines: 2,
minLines: 1,
),
)
else
ActivitySuggestionCardRow(
icon: Icons.event_note_outlined,
child: Text(
widget
.controller.updatedActivity.title,
style: theme.textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (widget.controller.isEditing)
ActivitySuggestionCardRow(
icon: Symbols.target,
child: TextFormField(
controller: widget.controller
.learningObjectivesController,
decoration: InputDecoration(
labelText: L10n.of(context)
.learningObjectiveLabel,
),
maxLines: 4,
minLines: 1,
),
)
else
ActivitySuggestionCardRow(
icon: Symbols.target,
child: Text(
widget.controller.updatedActivity
.learningObjective,
style: theme.textTheme.bodyLarge,
),
),
if (widget.controller.isEditing)
ActivitySuggestionCardRow(
icon: Symbols.steps,
child: TextFormField(
controller: widget.controller
.instructionsController,
decoration: InputDecoration(
labelText:
L10n.of(context).instructions,
),
maxLines: 8,
minLines: 1,
),
)
else
ActivitySuggestionCardRow(
icon: Symbols.steps,
child: Text(
widget.controller.updatedActivity
.instructions,
style: theme.textTheme.bodyLarge,
),
),
if (widget.controller.isEditing)
ActivitySuggestionCardRow(
icon: Icons.group_outlined,
child: TextFormField(
controller: widget.controller
.participantsController,
decoration: InputDecoration(
labelText:
L10n.of(context).classRoster,
),
maxLines: 1,
keyboardType: TextInputType.number,
validator: (value) {
if (value == null ||
value.isEmpty) {
return null;
}
try {
final val = int.parse(value);
if (val <= 0) {
return L10n.of(context)
.pleaseEnterInt;
}
if (val > 50) {
return L10n.of(context)
.maxFifty;
}
} catch (e) {
return L10n.of(context)
.pleaseEnterANumber;
}
return null;
},
),
)
else
ActivitySuggestionCardRow(
icon: Icons.group_outlined,
child: Text(
L10n.of(context).countParticipants(
widget.controller.updatedActivity
.req.numberOfParticipants,
),
style: theme.textTheme.bodyLarge,
),
),
if (widget.controller.isEditing)
ActivitySuggestionCardRow(
icon: Icons.school_outlined,
child: LanguageLevelDropdown(
initialLevel:
widget.controller.languageLevel,
onChanged: widget
.controller.setLanguageLevel,
),
)
else
ActivitySuggestionCardRow(
icon: Icons.school_outlined,
child: Text(
widget.controller.updatedActivity.req
.cefrLevel
.title(context),
style: theme.textTheme.bodyLarge,
),
),
if (widget.controller.isEditing)
ActivitySuggestionCardRow(
icon: Symbols.dictionary,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 60.0,
),
child: SingleChildScrollView(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: widget.controller.vocab
.mapIndexed(
(i, vocab) => Container(
padding: const EdgeInsets
.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
decoration: BoxDecoration(
color: theme
.colorScheme.primary
.withAlpha(
20,
),
borderRadius:
BorderRadius
.circular(
24.0,
),
),
child: MouseRegion(
cursor:
SystemMouseCursors
.click,
child: GestureDetector(
onTap: () => widget
.controller
.removeVocab(
i,
),
child: Row(
spacing: 4.0,
mainAxisSize:
MainAxisSize
.min,
children: [
Text(
vocab.lemma,
),
const Icon(
Icons.close,
size: 12.0,
),
],
),
),
),
),
)
.toList(),
),
),
),
)
else
ActivitySuggestionCardRow(
icon: Symbols.dictionary,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 60.0,
),
child: SingleChildScrollView(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: widget.controller.vocab
.map(
(vocab) => Container(
padding: const EdgeInsets
.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
decoration: BoxDecoration(
color: theme
.colorScheme.primary
.withAlpha(
20,
),
borderRadius:
BorderRadius
.circular(
24.0,
),
),
child: Text(
vocab.lemma,
style: theme.textTheme
.bodyMedium,
),
),
)
.toList(),
),
),
),
),
if (widget.controller.isEditing)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
),
child: Row(
spacing: 4.0,
children: [
Expanded(
child: TextFormField(
controller: widget
.controller.vocabController,
decoration: InputDecoration(
hintText: L10n.of(
context,
).addVocabulary,
),
maxLines: 1,
onFieldSubmitted: (_) => widget
.controller
.addVocab(),
),
),
IconButton(
padding: const EdgeInsets.all(
0.0,
),
constraints:
const BoxConstraints(), // override default min size of 48px
iconSize: 16.0,
icon: const Icon(
Icons.add_outlined,
),
onPressed:
widget.controller.addVocab,
),
],
),
),
],
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: widget.controller.isEditing
? Row(
spacing: 12.0,
children: [
Expanded(
child: ElevatedButton(
style: buttonStyle,
onPressed: widget.controller.saveEdits,
child: Row(
children: [
const Icon(Icons.save),
Expanded(
child: Text(
L10n.of(context).save,
textAlign: TextAlign.center,
),
),
],
),
),
),
Expanded(
child: ElevatedButton(
style: buttonStyle,
onPressed: widget.controller.clearEdits,
child: Row(
children: [
const Icon(Icons.cancel),
Expanded(
child: Text(
L10n.of(context).cancel,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
)
: Column(
spacing: 12.0,
children: [
Row(
spacing: 12.0,
children: [
Expanded(
child: ElevatedButton(
style: buttonStyle,
child: Row(
children: [
const Icon(Icons.edit),
Expanded(
child: Text(
L10n.of(context).edit,
textAlign: TextAlign.center,
),
),
],
),
onPressed: () => widget.controller
.setEditing(true),
),
),
if (widget.replaceActivity != null)
Expanded(
child: ElevatedButton(
style: buttonStyle,
onPressed: _onRegenerate,
child: Row(
children: [
const Icon(
Icons.lightbulb_outline,
),
Expanded(
child: Text(
L10n.of(context).regenerate,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
),
Row(
children: [
Expanded(
child: ElevatedButton(
style: buttonStyle,
onPressed: _launchActivity,
child: Row(
children: [
const Icon(Icons.send),
Expanded(
child: Text(
widget.buttonText,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
),
],
),
),
],
),
);
}
return ActivityRoomSelection(
controller: widget.controller,
backButton: BackButton(
onPressed: () => _setPageMode(
_PageMode.activity,
),
),
if (_loading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
return Form(
key: widget.controller.formKey,
child: ActivitySuggestionDialogContent(controller: this),
);
},
),
),
if (_pageMode == _PageMode.activity)
Positioned(
top: 4.0,
left: 4.0,
child: IconButton.filled(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface.withAlpha(170),
),
icon: Icon(
Icons.close_outlined,
color: theme.colorScheme.onSurface,
),
onPressed: Navigator.of(context).pop,
tooltip: L10n.of(context).close,
Positioned(
top: 4.0,
left: 4.0,
child: IconButton.filled(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface.withAlpha(170),
),
icon: Icon(
Icons.close_outlined,
color: theme.colorScheme.onSurface,
),
onPressed: Navigator.of(context).pop,
tooltip: L10n.of(context).close,
),
),
],
);
@ -750,3 +223,69 @@ class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
);
}
}
class NumberCounter extends StatelessWidget {
final int count;
final Function(int) update;
final int? min;
final int? max;
const NumberCounter({
super.key,
required this.count,
required this.update,
this.min,
this.max,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove),
iconSize: 24.0,
style: IconButton.styleFrom(
padding: const EdgeInsets.all(0.0),
),
onPressed: min == null || count - 1 >= min!
? () {
if (count > 0) {
update(count - 1);
}
}
: null,
),
Text(
count.toString(),
style: const TextStyle(
fontSize: 16.0,
),
),
IconButton(
icon: const Icon(Icons.add),
iconSize: 24.0,
style: max == null || count + 1 <= max!
? IconButton.styleFrom(
padding: const EdgeInsets.all(0.0),
)
: null,
onPressed: max == null || count + 1 <= max!
? () {
update(count + 1);
}
: null,
),
],
),
);
}
}

View file

@ -0,0 +1,712 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:material_symbols_icons/symbols.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_row.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class ActivitySuggestionDialogContent extends StatelessWidget {
final ActivitySuggestionDialogState controller;
const ActivitySuggestionDialogContent({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) {
switch (controller.widget.controller.launchState) {
case ActivityLaunchState.base:
return _ActivitySuggestionBaseContent(controller: controller);
case ActivityLaunchState.editing:
return _ActivitySuggestionEditContent(controller: controller);
case ActivityLaunchState.launching:
return _ActivitySuggestionLaunchContent(controller: controller);
}
}
}
class _ActivitySuggestionDialogImage extends StatelessWidget {
final ActivityPlannerBuilderState activityController;
final double width;
const _ActivitySuggestionDialogImage({
required this.activityController,
required this.width,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24.0),
width: (width / 2) + 42.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: activityController.avatar != null
? Image.memory(
activityController.avatar!,
fit: BoxFit.cover,
)
: activityController.updatedActivity.imageURL != null
? activityController.updatedActivity.imageURL!.startsWith("mxc")
? MxcImage(
uri: Uri.parse(
activityController.updatedActivity.imageURL!,
),
width: width / 2,
height: 200,
cacheKey: activityController.updatedActivity.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: activityController.updatedActivity.imageURL!,
fit: BoxFit.cover,
placeholder: (
context,
url,
) =>
const Center(
child: CircularProgressIndicator(),
),
errorWidget: (
context,
url,
error,
) =>
const SizedBox(),
)
: null,
),
);
}
}
class _ActivitySuggestionDialogFrame extends StatelessWidget {
final Widget topContent;
final List<Widget> centerContent;
final Widget bottomContent;
const _ActivitySuggestionDialogFrame({
required this.topContent,
required this.centerContent,
required this.bottomContent,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: SingleChildScrollView(
child: Column(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
topContent,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Column(
spacing: 14.0,
children: centerContent,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: bottomContent,
),
],
);
}
}
class _ActivitySuggestionBaseContent extends StatelessWidget {
final ActivitySuggestionDialogState controller;
const _ActivitySuggestionBaseContent({
required this.controller,
});
ActivityPlannerBuilderState get activityController =>
controller.widget.controller;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final topContent = _ActivitySuggestionDialogImage(
activityController: activityController,
width: controller.width,
);
final centerContent = [
ActivitySuggestionCardRow(
icon: Icons.event_note_outlined,
child: Text(
activityController.updatedActivity.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
ActivitySuggestionCardRow(
icon: Symbols.target,
child: Text(
activityController.updatedActivity.learningObjective,
style: const TextStyle(fontSize: 16),
),
),
ActivitySuggestionCardRow(
icon: Symbols.steps,
child: Text(
activityController.updatedActivity.instructions,
style: const TextStyle(fontSize: 16),
),
),
ActivitySuggestionCardRow(
icon: Icons.group_outlined,
child: Text(
L10n.of(context).countParticipants(
activityController.updatedActivity.req.numberOfParticipants,
),
style: const TextStyle(fontSize: 16),
),
),
ActivitySuggestionCardRow(
icon: Icons.school_outlined,
child: Text(
activityController.updatedActivity.req.cefrLevel.title(context),
style: const TextStyle(fontSize: 16),
),
),
ActivitySuggestionCardRow(
icon: Symbols.dictionary,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 60.0,
),
child: SingleChildScrollView(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: activityController.vocab
.map(
(vocab) => Container(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withAlpha(
20,
),
borderRadius: BorderRadius.circular(
24.0,
),
),
child: Text(
vocab.lemma,
style: const TextStyle(fontSize: 12),
),
),
)
.toList(),
),
),
),
),
];
final bottomContent = Column(
spacing: 12.0,
children: [
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 (controller.widget.replaceActivity != null)
Expanded(
child: ElevatedButton(
style: controller.buttonStyle,
onPressed: controller.onRegenerate,
child: Row(
children: [
const Icon(
Icons.lightbulb_outline,
),
Expanded(
child: Text(
L10n.of(context).regenerate,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
),
Row(
children: [
Expanded(
child: ElevatedButton(
style: controller.buttonStyle,
// onPressed: _launchActivity,
onPressed: () {
activityController.setLaunchState(
ActivityLaunchState.launching,
);
},
child: Row(
spacing: 12.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.save_outlined),
Text(
controller.widget.buttonText,
textAlign: TextAlign.center,
),
],
),
),
),
],
),
],
);
return _ActivitySuggestionDialogFrame(
topContent: topContent,
centerContent: centerContent,
bottomContent: bottomContent,
);
}
}
class _ActivitySuggestionEditContent extends StatelessWidget {
final ActivitySuggestionDialogState controller;
const _ActivitySuggestionEditContent({
required this.controller,
});
ActivityPlannerBuilderState get activityController =>
controller.widget.controller;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final topContent = Stack(
alignment: Alignment.bottomCenter,
children: [
_ActivitySuggestionDialogImage(
activityController: activityController,
width: controller.width,
),
InkWell(
borderRadius: BorderRadius.circular(90),
onTap: activityController.selectAvatar,
child: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.secondary,
radius: 20.0,
child: Icon(
Icons.add_a_photo_outlined,
size: 20.0,
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
],
);
final centerContent = [
ActivitySuggestionCardRow(
icon: Icons.event_note_outlined,
child: TextFormField(
controller: activityController.titleController,
decoration: InputDecoration(
labelText: L10n.of(context).activityTitle,
),
maxLines: 2,
minLines: 1,
),
),
ActivitySuggestionCardRow(
icon: Symbols.target,
child: TextFormField(
controller: activityController.learningObjectivesController,
decoration: InputDecoration(
labelText: L10n.of(context).learningObjectiveLabel,
),
maxLines: 4,
minLines: 1,
),
),
ActivitySuggestionCardRow(
icon: Symbols.steps,
child: TextFormField(
controller: activityController.instructionsController,
decoration: InputDecoration(
labelText: L10n.of(context).instructions,
),
maxLines: 8,
minLines: 1,
),
),
ActivitySuggestionCardRow(
icon: Icons.group_outlined,
child: TextFormField(
controller: activityController.participantsController,
decoration: InputDecoration(
labelText: L10n.of(context).classRoster,
),
maxLines: 1,
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return null;
}
try {
final val = int.parse(value);
if (val <= 0) {
return L10n.of(context).pleaseEnterInt;
}
if (val > 50) {
return L10n.of(context).maxFifty;
}
} catch (e) {
return L10n.of(context).pleaseEnterANumber;
}
return null;
},
),
),
ActivitySuggestionCardRow(
icon: Icons.school_outlined,
child: LanguageLevelDropdown(
initialLevel: activityController.languageLevel,
onChanged: activityController.setLanguageLevel,
),
),
ActivitySuggestionCardRow(
icon: Symbols.dictionary,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 60.0,
),
child: SingleChildScrollView(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: activityController.vocab
.mapIndexed(
(i, vocab) => Container(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withAlpha(
20,
),
borderRadius: BorderRadius.circular(
24.0,
),
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => activityController.removeVocab(
i,
),
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
Text(
vocab.lemma,
),
const Icon(
Icons.close,
size: 12.0,
),
],
),
),
),
),
)
.toList(),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
),
child: Row(
spacing: 4.0,
children: [
Expanded(
child: TextFormField(
controller: activityController.vocabController,
decoration: InputDecoration(
hintText: L10n.of(
context,
).addVocabulary,
),
maxLines: 1,
onFieldSubmitted: (_) => activityController.addVocab(),
),
),
IconButton(
padding: const EdgeInsets.all(
0.0,
),
constraints:
const BoxConstraints(), // override default min size of 48px
iconSize: 16.0,
icon: const Icon(
Icons.add_outlined,
),
onPressed: activityController.addVocab,
),
],
),
),
];
final bottomContent = Row(
spacing: 12.0,
children: [
Expanded(
child: ElevatedButton(
style: controller.buttonStyle,
onPressed: activityController.saveEdits,
child: Row(
children: [
const Icon(Icons.save),
Expanded(
child: Text(
L10n.of(context).save,
textAlign: TextAlign.center,
),
),
],
),
),
),
Expanded(
child: ElevatedButton(
style: controller.buttonStyle,
onPressed: activityController.clearEdits,
child: Row(
children: [
const Icon(Icons.cancel),
Expanded(
child: Text(
L10n.of(context).cancel,
textAlign: TextAlign.center,
),
),
],
),
),
),
],
);
return _ActivitySuggestionDialogFrame(
topContent: topContent,
centerContent: centerContent,
bottomContent: bottomContent,
);
}
}
class _ActivitySuggestionLaunchContent extends StatelessWidget {
final ActivitySuggestionDialogState controller;
const _ActivitySuggestionLaunchContent({
required this.controller,
});
ActivityPlannerBuilderState get activityController =>
controller.widget.controller;
@override
Widget build(BuildContext context) {
final topContent = Padding(
padding: const EdgeInsets.all(24.0),
child: Avatar(
mxContent: activityController.room.avatar,
name: activityController.room.getLocalizedDisplayname(
MatrixLocals(
L10n.of(context),
),
),
size: (controller.width / 2),
borderRadius: BorderRadius.circular(20.0),
),
);
final centerContent = [
ActivitySuggestionCardRow(
leading: Avatar(
mxContent: activityController.room.avatar,
name: activityController.room.getLocalizedDisplayname(
MatrixLocals(
L10n.of(context),
),
),
size: 24.0,
borderRadius: BorderRadius.circular(4),
),
child: Text(
activityController.room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
ActivitySuggestionCardRow(
leading: activityController.updatedActivity.imageURL != null
? ClipRRect(
borderRadius: BorderRadius.circular(4.0),
child: activityController.updatedActivity.imageURL!
.startsWith("mxc")
? MxcImage(
uri: Uri.parse(
activityController.updatedActivity.imageURL!,
),
width: 24.0,
height: 24.0,
cacheKey: activityController.updatedActivity.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: activityController.updatedActivity.imageURL!,
fit: BoxFit.cover,
width: 24.0,
height: 24.0,
placeholder: (
context,
url,
) =>
const Center(
child: CircularProgressIndicator(),
),
errorWidget: (
context,
url,
error,
) =>
const SizedBox(),
),
)
: const Icon(
Icons.event_note_outlined,
size: 24.0,
),
child: Text(
activityController.updatedActivity.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
ActivitySuggestionCardRow(
icon: Icons.groups,
child: Text(
L10n.of(context).minimumActivityParticipants(
activityController.updatedActivity.req.numberOfParticipants,
),
style: const TextStyle(fontSize: 16),
),
),
ActivitySuggestionCardRow(
icon: Icons.radar,
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
L10n.of(context).numberOfActivities,
style: const TextStyle(fontSize: 16),
),
NumberCounter(
count: activityController.numActivities,
update: activityController.setNumActivities,
min: 1,
max: 5,
),
],
),
),
];
final bottomContent = ElevatedButton(
style: controller.buttonStyle,
onPressed: controller.launchActivity,
child: Row(
spacing: 12.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.send_outlined),
Text(
L10n.of(context).launchToSpace,
textAlign: TextAlign.center,
),
],
),
);
return _ActivitySuggestionDialogFrame(
topContent: topContent,
centerContent: centerContent,
bottomContent: bottomContent,
);
}
}

View file

@ -26,12 +26,12 @@ import 'package:fluffychat/widgets/matrix.dart';
class ActivitySuggestionsArea extends StatefulWidget {
final Axis? scrollDirection;
final Room? room;
final Room room;
const ActivitySuggestionsArea({
super.key,
this.scrollDirection,
this.room,
required this.room,
});
@override
ActivitySuggestionsAreaState createState() => ActivitySuggestionsAreaState();
@ -173,7 +173,7 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
builder: (controller) {
return ActivitySuggestionDialog(
controller: controller,
buttonText: L10n.of(context).launchActivityButton,
buttonText: L10n.of(context).saveAndLaunch,
replaceActivity: (a) =>
_onReplaceActivity(index, a),
);

View file

@ -3,4 +3,5 @@ class ActivitySuggestionsConstants {
static const String crayonIconPath = "make_your_own_icon.svg";
static const String modeImageFileStart = "activityplanner_mode_";
static const String makeActivityAssetPath = "Spark+imaginative.png";
static const String endActivityAssetPath = "EndActivityMsg.png";
}

View file

@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivitySummaryRepo {
static final GetStorage _activitySummaryStorage =
GetStorage('activity_summary_storage');
static void set(
ActivitySummaryRequestModel request,
ActivitySummaryResponseModel response,
) {
_activitySummaryStorage.write(_storageKey(request), response.toJson());
}
static String _storageKey(ActivitySummaryRequestModel request) {
// You may want to customize this key based on request fields
return request.activity.hashCode.toString();
}
static Future<ActivitySummaryResponseModel> get(
ActivitySummaryRequestModel request,
) async {
final cachedJson = _activitySummaryStorage.read(_storageKey(request));
if (cachedJson != null) {
final cached = ActivitySummaryResponseModel.fromJson(cachedJson);
return cached;
}
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res = await req.post(
url: PApiUrls.activitySummary,
body: request.toJson(),
);
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = ActivitySummaryResponseModel.fromJson(decodedBody);
set(request, response);
return response;
}
}

View file

@ -0,0 +1,96 @@
// Add this import for the participant summary model
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart';
class ActivitySummaryResultsMessage {
final String userId;
final String sent;
final String? written;
final List<String> tool;
final DateTime time;
ActivitySummaryResultsMessage({
required this.userId,
required this.sent,
this.written,
required this.tool,
required this.time,
});
factory ActivitySummaryResultsMessage.fromJson(Map<String, dynamic> json) {
return ActivitySummaryResultsMessage(
userId: json['user_id'] as String,
sent: json['sent'] as String,
written: json['written'] as String?,
tool: (json['tool'] as List).map((e) => e as String).toList(),
time: DateTime.parse(json['time'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'user_id': userId,
'sent': sent,
if (written != null) 'written': written,
'tool': tool,
'time': time.toIso8601String(),
};
}
}
class ContentFeedbackModel {
final String feedback;
final ActivitySummaryResponseModel content;
ContentFeedbackModel({
required this.feedback,
required this.content,
});
factory ContentFeedbackModel.fromJson(Map<String, dynamic> json) {
return ContentFeedbackModel(
feedback: json['feedback'] as String,
content: ActivitySummaryResponseModel.fromJson(json['content']),
);
}
Map<String, dynamic> toJson() {
return {
'feedback': feedback,
'content': content.toJson(),
};
}
}
class ActivitySummaryRequestModel {
final ActivityPlanModel activity;
final List<ActivitySummaryResultsMessage> activityResults;
final List<ContentFeedbackModel> contentFeedback;
ActivitySummaryRequestModel({
required this.activity,
required this.activityResults,
required this.contentFeedback,
});
Map<String, dynamic> toJson() {
return {
'activity': activity.toJson(),
'activity_results': activityResults.map((e) => e.toJson()).toList(),
'content_feedback': contentFeedback.map((e) => e.toJson()).toList(),
};
}
factory ActivitySummaryRequestModel.fromJson(Map<String, dynamic> json) {
return ActivitySummaryRequestModel(
activity: ActivityPlanModel.fromJson(json['activity']),
activityResults: (json['activity_results'] as List)
.map((e) => ActivitySummaryResultsMessage.fromJson(e))
.toList(),
contentFeedback: (json['content_feedback'] as List)
.map((e) => ContentFeedbackModel.fromJson(e))
.toList(),
);
}
}

View file

@ -0,0 +1,58 @@
class ParticipantSummaryModel {
final String participantId;
final String feedback;
final String cefrLevel;
final List<String> superlatives;
ParticipantSummaryModel({
required this.participantId,
required this.feedback,
required this.cefrLevel,
required this.superlatives,
});
factory ParticipantSummaryModel.fromJson(Map<String, dynamic> json) {
return ParticipantSummaryModel(
participantId: json['participant_id'] as String,
feedback: json['feedback'] as String,
cefrLevel: json['cefr_level'] as String,
superlatives:
(json['superlatives'] as List).map((e) => e as String).toList(),
);
}
Map<String, dynamic> toJson() {
return {
'participant_id': participantId,
'feedback': feedback,
'cefr_level': cefrLevel,
'superlatives': superlatives,
};
}
}
class ActivitySummaryResponseModel {
final List<ParticipantSummaryModel> participants;
final String summary;
ActivitySummaryResponseModel({
required this.participants,
required this.summary,
});
factory ActivitySummaryResponseModel.fromJson(Map<String, dynamic> json) {
return ActivitySummaryResponseModel(
participants: (json['participants'] as List)
.map((e) => ParticipantSummaryModel.fromJson(e))
.toList(),
summary: json['summary'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'participants': participants.map((e) => e.toJson()).toList(),
'summary': summary,
};
}
}

View file

@ -108,7 +108,11 @@ class GetAnalyticsController extends BaseController {
data: {},
);
} finally {
_updateAnalyticsStream(points: 0, newConstructs: []);
_updateAnalyticsStream(
type: AnalyticsUpdateType.local,
points: 0,
newConstructs: [],
);
if (!initCompleter.isCompleted) initCompleter.complete();
_initializing = false;
}
@ -130,7 +134,18 @@ class GetAnalyticsController extends BaseController {
Future<void> _onAnalyticsUpdate(
AnalyticsUpdate analyticsUpdate,
) async {
final validTypes = [AnalyticsUpdateType.local, AnalyticsUpdateType.server];
if (!validTypes.contains(analyticsUpdate.type)) {
_updateAnalyticsStream(
type: analyticsUpdate.type,
points: 0,
newConstructs: [],
);
return;
}
if (analyticsUpdate.isLogout) return;
final oldLevel = constructListModel.level;
final offset =
@ -176,6 +191,7 @@ class GetAnalyticsController extends BaseController {
_onUnlockMorphLemmas(newUnlockedMorphs);
}
_updateAnalyticsStream(
type: analyticsUpdate.type,
points: analyticsUpdate.newConstructs.fold<int>(
0,
(previousValue, element) => previousValue + element.xp,
@ -193,12 +209,14 @@ class GetAnalyticsController extends BaseController {
}
void _updateAnalyticsStream({
required AnalyticsUpdateType type,
required int points,
required List<ConstructIdentifier> newConstructs,
String? targetID,
}) =>
analyticsStream.add(
AnalyticsStreamUpdate(
type: type,
points: points,
newConstructs: newConstructs,
targetID: targetID,
@ -570,6 +588,13 @@ class GetAnalyticsController extends BaseController {
return summary;
}
List<Room> get archivedActivities {
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) return [];
final ids = analyticsRoom.activityRoomIds;
return ids.map((id) => _client.getRoomById(id)).whereType<Room>().toList();
}
}
class AnalyticsCacheEntry {
@ -602,11 +627,13 @@ class AnalyticsCacheEntry {
}
class AnalyticsStreamUpdate {
final AnalyticsUpdateType type;
final int points;
final List<ConstructIdentifier> newConstructs;
final String? targetID;
AnalyticsStreamUpdate({
required this.type,
required this.points,
required this.newConstructs,
this.targetID,

View file

@ -15,7 +15,7 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum AnalyticsUpdateType { server, local }
enum AnalyticsUpdateType { server, local, activities }
/// handles the processing of analytics for
/// 1) messages sent by the user and
@ -24,6 +24,7 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
late PangeaController _pangeaController;
StreamController<AnalyticsUpdate> analyticsUpdateStream =
StreamController.broadcast();
StreamSubscription<AnalyticsStream>? _analyticsStream;
StreamSubscription? _languageStream;
Timer? _updateTimer;
@ -414,6 +415,41 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
_pangeaController.getAnalytics.locallyCachedSentConstructs,
);
}
Future<void> sendActivityAnalytics(String roomId) async {
if (_pangeaController.matrixState.client.userID == null) return;
if (_pangeaController.languageController.userL2 == null) return;
final Room? analyticsRoom = await _client.getMyAnalyticsRoom(
_pangeaController.languageController.userL2!,
);
if (analyticsRoom == null) return;
await analyticsRoom.addActivityRoomId(roomId);
analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.activities,
[],
),
);
}
Future<void> removeActivityAnalytics(String roomId) async {
if (_pangeaController.matrixState.client.userID == null) return;
if (_pangeaController.languageController.userL2 == null) return;
final Room? analyticsRoom = await _client.getMyAnalyticsRoom(
_pangeaController.languageController.userL2!,
);
if (analyticsRoom == null) return;
await analyticsRoom.removeActivityRoomId(roomId);
analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.activities,
[],
),
);
}
}
class AnalyticsStream {

View file

@ -268,4 +268,51 @@ extension AnalyticsRoomExtension on Room {
);
}
}
List<String> get activityRoomIds {
final state = getState(PangeaEventTypes.activityRoomIds);
if (state?.content[ModelKey.roomIds] is List) {
return List<String>.from(state!.content[ModelKey.roomIds] as List);
}
return [];
}
Future<void> addActivityRoomId(String roomId) async {
final List<String> ids = List.from(activityRoomIds);
if (ids.contains(roomId)) return;
final prevLength = ids.length;
ids.add(roomId);
final syncFuture = client.waitForRoomInSync(id, join: true);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRoomIds,
"",
{ModelKey.roomIds: ids},
);
final newLength = activityRoomIds.length;
if (newLength == prevLength) {
await syncFuture;
}
}
Future<void> removeActivityRoomId(String roomId) async {
final List<String> ids = List.from(activityRoomIds);
if (!ids.contains(roomId)) return;
final prevLength = ids.length;
ids.remove(roomId);
final syncFuture = client.waitForRoomInSync(id, join: true);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRoomIds,
"",
{ModelKey.roomIds: ids},
);
final newLength = activityRoomIds.length;
if (newLength == prevLength) {
await syncFuture;
}
}
}

View file

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_page/activity_archive_view.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityArchive extends StatefulWidget {
const ActivityArchive({super.key});
@override
ActivityArchiveState createState() => ActivityArchiveState();
}
class ActivityArchiveState extends State<ActivityArchive> {
List<Room> get archive =>
MatrixState.pangeaController.getAnalytics.archivedActivities;
Future<void> removeArchivedChat(Room room) async {
await room.leave();
await MatrixState.pangeaController.putAnalytics
.removeActivityAnalytics(room.id);
setState(() {});
}
@override
Widget build(BuildContext context) => ActivityArchiveView(controller: this);
}

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pangea/analytics_page/activity_archive.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
class ActivityArchiveView extends StatelessWidget {
final ActivityArchiveState controller;
const ActivityArchiveView({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) {
return MaxWidthBody(
withScrolling: false,
child: Builder(
builder: (BuildContext context) {
if (controller.archive.isEmpty) {
return const Center(
child: Icon(Icons.archive_outlined, size: 80),
);
}
return ListView.builder(
itemCount: controller.archive.length,
itemBuilder: (BuildContext context, int i) => ChatListItem(
controller.archive[i],
onForget: () {
showFutureLoadingDialog(
context: context,
future: () => controller.removeArchivedChat(
controller.archive[i],
),
);
},
onTap: () =>
context.go('/rooms/analytics/${controller.archive[i].id}'),
),
);
},
),
);
}
}

View file

@ -6,6 +6,7 @@ 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';
import 'package:fluffychat/pangea/analytics_page/analytics_page.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart';
import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart';
@ -67,6 +68,9 @@ class AnalyticsPageView extends StatelessWidget {
constructZoom: controller.widget.constructZoom,
view: ConstructTypeEnum.vocab,
);
} else if (controller.selectedIndicator ==
ProgressIndicatorEnum.activities) {
return const ActivityArchive();
}
return const SizedBox();

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
@ -99,6 +100,8 @@ class LearningProgressIndicatorsState
final userL1 = MatrixState.pangeaController.languageController.userL1;
final userL2 = MatrixState.pangeaController.languageController.userL2;
final isColumnMode = FluffyThemes.isColumnMode(context);
return Row(
children: [
Expanded(
@ -111,24 +114,53 @@ class LearningProgressIndicatorsState
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(
spacing: 16.0,
children: ConstructTypeEnum.values
.map(
(c) => HoverButton(
selected: widget.selected == c.indicator,
onPressed: () {
context.go(
"/rooms/analytics?mode=${c.string}",
);
},
child: ProgressIndicatorBadge(
indicator: c.indicator,
loading: _loading,
points: uniqueLemmas(c.indicator),
),
spacing: isColumnMode ? 16.0 : 4.0,
children: [
...ConstructTypeEnum.values.map(
(c) => HoverButton(
selected: widget.selected == c.indicator,
onPressed: () {
context.go(
"/rooms/analytics?mode=${c.string}",
);
},
child: ProgressIndicatorBadge(
indicator: c.indicator,
loading: _loading,
points: uniqueLemmas(c.indicator),
),
)
.toList(),
),
),
HoverButton(
selected: widget.selected ==
ProgressIndicatorEnum.activities,
onPressed: () {
context.go(
"/rooms/analytics?mode=activities",
);
},
child: Tooltip(
message: ProgressIndicatorEnum.activities
.tooltip(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
size: 18,
Icons.radar,
color: Theme.of(context).colorScheme.primary,
weight: 1000,
),
const SizedBox(width: 6.0),
AnimatedFloatingNumber(
number: MatrixState.pangeaController
.getAnalytics.archivedActivities.length,
),
],
),
),
),
],
),
),
HoverButton(

View file

@ -30,9 +30,8 @@ class ProgressIndicatorBadge extends StatelessWidget {
),
const SizedBox(width: 6.0),
!loading
? _AnimatedFloatingNumber(
? AnimatedFloatingNumber(
number: points,
indicator: indicator,
)
: const SizedBox(
height: 8,
@ -47,21 +46,19 @@ class ProgressIndicatorBadge extends StatelessWidget {
}
}
class _AnimatedFloatingNumber extends StatefulWidget {
class AnimatedFloatingNumber extends StatefulWidget {
final int number;
final ProgressIndicatorEnum indicator;
const _AnimatedFloatingNumber({
const AnimatedFloatingNumber({
super.key,
required this.number,
required this.indicator,
});
@override
State<_AnimatedFloatingNumber> createState() =>
_AnimatedFloatingNumberState();
State<AnimatedFloatingNumber> createState() => AnimatedFloatingNumberState();
}
class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber>
class AnimatedFloatingNumberState extends State<AnimatedFloatingNumber>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnim;
@ -85,7 +82,7 @@ class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber>
}
@override
void didUpdateWidget(covariant _AnimatedFloatingNumber oldWidget) {
void didUpdateWidget(covariant AnimatedFloatingNumber oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.number > _lastNumber!) {
_floatingNumber = widget.number;
@ -109,7 +106,7 @@ class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber>
final TextStyle indicatorStyle = TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: widget.indicator.color(context),
color: Theme.of(context).colorScheme.primary,
);
return Stack(
alignment: Alignment.center,

View file

@ -8,7 +8,8 @@ import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
enum ProgressIndicatorEnum {
level,
wordsUsed,
morphsUsed;
morphsUsed,
activities;
static ProgressIndicatorEnum? fromString(String value) {
switch (value) {
@ -18,6 +19,8 @@ enum ProgressIndicatorEnum {
return ProgressIndicatorEnum.morphsUsed;
case 'level':
return ProgressIndicatorEnum.level;
case 'activities':
return ProgressIndicatorEnum.activities;
default:
return null;
}
@ -33,6 +36,8 @@ extension ProgressIndicatorsExtension on ProgressIndicatorEnum {
return Symbols.toys_and_games;
case ProgressIndicatorEnum.level:
return Icons.star;
case ProgressIndicatorEnum.activities:
return Icons.radar;
}
}
@ -51,6 +56,8 @@ extension ProgressIndicatorsExtension on ProgressIndicatorEnum {
return L10n.of(context).level;
case ProgressIndicatorEnum.morphsUsed:
return L10n.of(context).grammar;
case ProgressIndicatorEnum.activities:
return L10n.of(context).activities;
}
}

View file

@ -13,6 +13,8 @@ class RoomDefaults {
"redact": 50,
"events": {
PangeaEventTypes.activityPlan: 0,
PangeaEventTypes.activityRole: 0,
PangeaEventTypes.activitySummary: 0,
"m.room.power_levels": 100,
"m.room.pinned_events": 50,
},
@ -38,6 +40,8 @@ class RoomDefaults {
"redact": 50,
"events": {
PangeaEventTypes.activityPlan: 50,
PangeaEventTypes.activityRole: 0,
PangeaEventTypes.activitySummary: 0,
"m.room.power_levels": 100,
"m.room.pinned_events": 50,
},

View file

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import '../../../config/app_config.dart';
class ActivityRoleStateMessage extends StatelessWidget {
final Event event;
const ActivityRoleStateMessage(this.event, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final senderName = event.senderId == event.room.client.userID
? L10n.of(context).you
: event.senderFromMemoryOrFallback.calcDisplayname();
String role = L10n.of(context).participant;
bool finished = false;
try {
final roleContent = event.content['role'] as String?;
if (roleContent != null) {
role = roleContent;
}
finished = event.content['finishedAt'] != null;
} catch (e) {
// If the role is not found, we keep the default participant role.
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Center(
child: Padding(
padding: const EdgeInsets.all(4),
child: Material(
color: theme.colorScheme.surface.withAlpha(128),
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 3),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Text(
finished
? L10n.of(context).finishedTheActivity(senderName)
: L10n.of(context).joinedTheActivity(senderName, role),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12 * AppConfig.fontSizeFactor,
decoration:
event.redacted ? TextDecoration.lineThrough : null,
),
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat/events/state_message.dart';
import 'package:fluffychat/pangea/activity_planner/activity_participant_indicator.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
class ActivityStateEvent extends StatelessWidget {
final Event event;
const ActivityStateEvent({super.key, required this.event});
@override
Widget build(BuildContext context) {
try {
final activity = ActivityPlanModel.fromJson(event.content);
final roles = event.room.activityRoles;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: Column(
spacing: 12.0,
children: [
Text(activity.markdown),
if (roles.isNotEmpty)
Wrap(
spacing: 12.0,
runSpacing: 12.0,
children: event.room.activityRoles.map((role) {
return ActivityParticipantIndicator(
role: role,
displayname: role.userId.localpart,
);
}).toList(),
),
],
),
);
} catch (e) {
return StateMessage(event);
}
}
}

View file

@ -321,9 +321,9 @@ class RoomDetailsButtonRowState extends State<RoomDetailsButtonRow> {
title: l10n.activities,
icon: const Icon(Icons.event_note_outlined, size: 30.0),
onPressed: () => context.go("/rooms/${room.id}/details/planner"),
visible: room.canChangeStateEvent(PangeaEventTypes.activityPlan) ||
visible: room.isSpace,
enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan) &&
room.isSpace,
enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan),
),
ButtonDetails(
title: l10n.permissions,

View file

@ -179,4 +179,5 @@ class ModelKey {
"number_of_participants";
static const String autoIGC = "auto_igc";
static const String roomIds = "room_ids";
}

View file

@ -61,7 +61,7 @@ class PangeaController {
int? randomint;
PangeaController({required this.matrix, required this.matrixState}) {
_setup();
_setLanguageStream();
_setLanguageSubscription();
randomint = Random().nextInt(2000);
}
@ -184,7 +184,7 @@ class PangeaController {
// Initialize analytics data
putAnalytics.initialize();
getAnalytics.initialize();
_setLanguageStream();
_setLanguageSubscription();
userController.reinitialize().then((_) {
final l1 = userController.profile.userSettings.sourceLanguage;
@ -212,7 +212,7 @@ class PangeaController {
await getAnalytics.initialize();
}
void _setLanguageStream() {
void _setLanguageSubscription() {
_languageStream?.cancel();
_languageStream = userController.languageStream.stream.listen(
(_) => clearCache(exclude: ["analytics_storage"]),

View file

@ -65,6 +65,7 @@ class PApiUrls {
static String topicList = "${PApiUrls.choreoEndpoint}/topics";
static String activityPlanSearch =
"${PApiUrls.choreoEndpoint}/activity_plan/search";
static String activitySummary = "${PApiUrls.choreoEndpoint}/activity_summary";
static String morphFeaturesAndTags = "${PApiUrls.choreoEndpoint}/morphs";
static String constructSummary =

View file

@ -26,6 +26,8 @@ class PangeaEventTypes {
static const capacity = "pangea.capacity";
static const activityPlan = "pangea.activity_plan";
static const activityRole = "pangea.activity_roles";
static const activitySummary = "pangea.activity_summary";
static const userAge = "pangea.user_age";
@ -44,4 +46,6 @@ class PangeaEventTypes {
/// Profile information related to a user's analytics
static const profileAnalytics = "pangea.analytics_profile";
static const activityRoomIds = "pangea.activity_room_ids";
}

View file

@ -9,24 +9,27 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.dart';
import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/extensions/join_rule_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import '../choreographer/models/choreo_record.dart';
import '../events/constants/pangea_event_types.dart';
import '../events/models/representation_content_model.dart';

View file

@ -94,8 +94,53 @@ extension ChildrenAndParentsRoomExtension on Room {
int get spaceChildCount => client.rooms
.where(
(r) => spaceChildren.any(
(child) => r.id == child.roomId && !r.isAnalyticsRoom,
(child) => r.id == child.roomId && !r.isHiddenRoom,
),
)
.length;
Future<void> addSubspace(BuildContext context) async {
if (!isSpace) return;
final names = await showTextInputDialog(
context: context,
title: L10n.of(context).createNewSpace,
hintText: L10n.of(context).spaceName,
minLines: 1,
maxLines: 1,
maxLength: 64,
validator: (text) {
if (text.isEmpty) {
return L10n.of(context).pleaseChoose;
}
return null;
},
okLabel: L10n.of(context).create,
cancelLabel: L10n.of(context).cancel,
);
if (names == null) return;
await showFutureLoadingDialog(
context: context,
future: () async {
await postLoad();
final resp = await client.createRoom(
name: names,
visibility: RoomDefaults.spaceChildVisibility,
creationContent: {'type': 'm.space'},
initialState: [
RoomDefaults.defaultSpacePowerLevels(client.userID!),
await client.pangeaJoinRules(
'knock_restricted',
allow: [
{
"type": "m.room_membership",
"room_id": id,
}
],
),
],
);
await addToSpace(resp);
},
);
}
}

View file

@ -10,7 +10,7 @@ extension EventsRoomExtension on Room {
for (final child in spaceChildren) {
if (child.roomId == null) continue;
final Room? room = client.getRoomById(child.roomId!);
if (room == null || room.isAnalyticsRoom) continue;
if (room == null || room.isHiddenRoom) continue;
try {
await room.leave();
} catch (e, s) {
@ -274,78 +274,6 @@ extension EventsRoomExtension on Room {
);
}
Future<void> sendActivityPlan(
ActivityPlanModel activity, {
Uint8List? avatar,
String? filename,
}) async {
BookmarkedActivitiesRepo.save(activity);
String? imageURL = activity.imageURL;
final eventId = await pangeaSendTextEvent(
activity.markdown,
messageTag: ModelKey.messageTagActivityPlan,
);
Uint8List? bytes = avatar;
if (imageURL != null && bytes == null) {
try {
final resp = await http
.get(Uri.parse(imageURL))
.timeout(const Duration(seconds: 5));
bytes = resp.bodyBytes;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"avatarURL": imageURL,
},
);
}
}
if (bytes != null && imageURL == null) {
final url = await client.uploadContent(
bytes,
filename: filename,
);
imageURL = url.toString();
}
MatrixFile? file;
if (filename != null && bytes != null) {
file = MatrixFile(
bytes: bytes,
name: filename,
);
}
if (file != null) {
final content = <String, dynamic>{
'msgtype': file.msgType,
'body': file.name,
'filename': file.name,
'url': imageURL,
ModelKey.messageTags: ModelKey.messageTagActivityPlan,
};
await sendEvent(content);
}
if (canChangeStateEvent(PangeaEventTypes.activityPlan)) {
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityPlan,
"",
activity.toJson(),
);
if (eventId != null && canChangeStateEvent(EventTypes.RoomPinnedEvents)) {
await setPinnedEvents([eventId]);
}
}
}
/// Get a list of events in the room that are of type [PangeaEventTypes.construct]
/// and have the sender as [userID]. If [count] is provided, the function will
/// return at most [count] events.

View file

@ -34,4 +34,9 @@ extension RoomInformationRoomExtension on Room {
bool get isAnalyticsRoom =>
getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
PangeaRoomTypes.analytics;
bool get isHiddenActivityRoom =>
activityRole(client.userID!)?.isArchived ?? false;
bool get isHiddenRoom => isAnalyticsRoom || isHiddenActivityRoom;
}

View file

@ -14,53 +14,10 @@ extension RoomSettingsRoomExtension on Room {
return t is int ? t : null;
}
IconData? get roomTypeIcon {
if (membership == Membership.invite) return Icons.add;
if (isSpace) return Icons.school;
if (isAnalyticsRoom) return Icons.analytics;
if (isDirectChat) return Icons.forum;
return Icons.group;
}
Text nameAndRoomTypeIcon([TextStyle? textStyle]) => Text.rich(
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
TextSpan(
children: [
WidgetSpan(
child: Icon(roomTypeIcon),
),
TextSpan(
text: ' $name',
),
],
),
);
BotOptionsModel? get botOptions {
if (isSpace) return null;
final stateEvent = getState(PangeaEventTypes.botOptions);
if (stateEvent == null) return null;
return BotOptionsModel.fromJson(stateEvent.content);
}
ActivityPlanModel? get activityPlan {
final stateEvent = getState(PangeaEventTypes.activityPlan);
if (stateEvent == null) return null;
try {
return ActivityPlanModel.fromJson(stateEvent.content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"stateEvent": stateEvent.content,
},
);
return null;
}
}
}

View file

@ -0,0 +1,146 @@
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

@ -136,7 +136,10 @@ abstract class ClientManager {
PangeaEventTypes.userSetLemmaInfo,
EventTypes.RoomJoinRules,
PangeaEventTypes.activityPlan,
PangeaEventTypes.activityRole,
PangeaEventTypes.activitySummary,
PangeaEventTypes.constructSummary,
PangeaEventTypes.activityRoomIds,
// Pangea#
},
logLevel: kReleaseMode ? Level.warning : Level.verbose,

View file

@ -1,36 +1,13 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import '../../config/app_config.dart';
extension VisibleInGuiExtension on List<Event> {
List<Event> filterByVisibleInGui({String? exceptionEventId}) {
final visibleEvents =
where((e) => e.isVisibleInGui || e.eventId == exceptionEventId)
.toList();
// Hide creation state events:
if (visibleEvents.isNotEmpty &&
visibleEvents.last.type == EventTypes.RoomCreate) {
var i = visibleEvents.length - 2;
while (i > 0) {
final event = visibleEvents[i];
if (!event.isState) break;
if (event.type == EventTypes.Encryption) {
i--;
continue;
}
if (event.type == EventTypes.RoomMember &&
event.roomMemberChangeType == RoomMemberChangeType.acceptInvite) {
i--;
continue;
}
visibleEvents.removeAt(i);
i--;
}
}
return visibleEvents;
}
List<Event> filterByVisibleInGui({String? exceptionEventId}) => where(
(event) => event.isVisibleInGui || event.eventId == exceptionEventId,
).toList();
}
extension IsStateExtension on Event {
@ -46,7 +23,10 @@ extension IsStateExtension on Event {
// if we enabled to hide all redacted events, don't show those
(!AppConfig.hideRedactedEvents || !redacted) &&
// if we enabled to hide all unknown events, don't show those
(!AppConfig.hideUnknownEvents || isEventTypeKnown) &&
// #Pangea
// (!AppConfig.hideUnknownEvents || isEventTypeKnown) &&
(!AppConfig.hideUnknownEvents || pangeaIsEventTypeKnown) &&
// Pangea#
// remove state events that we don't want to render
(isState || !AppConfig.hideAllStateEvents) &&
// #Pangea
@ -75,6 +55,13 @@ extension IsStateExtension on Event {
}.contains(type);
// #Pangea
bool get pangeaIsEventTypeKnown =>
isEventTypeKnown ||
[
PangeaEventTypes.activityPlan,
PangeaEventTypes.activityRole,
].contains(type);
// we're filtering out some state events that we don't want to render
static const Set<String> importantStateEvents = {
EventTypes.Encryption,
@ -82,6 +69,8 @@ extension IsStateExtension on Event {
EventTypes.RoomMember,
EventTypes.RoomTombstone,
EventTypes.CallInvite,
PangeaEventTypes.activityPlan,
PangeaEventTypes.activityRole,
};
// Pangea#
}

View file

@ -73,7 +73,7 @@ class _ShareScaffoldDialogState extends State<ShareScaffoldDialog> {
room.canSendDefaultMessages &&
!room.isSpace &&
// #Pangea
!room.isAnalyticsRoom &&
!room.isHiddenRoom &&
// Pangea#
room.membership == Membership.join,
)

View file

@ -26,7 +26,7 @@ class UnreadRoomsBadge extends StatelessWidget {
.client
.rooms
// #Pangea
.where((r) => !r.isAnalyticsRoom)
.where((r) => !r.isHiddenRoom)
// Pangea#
.where(filter)
.where((r) => (r.isUnread || r.membership == Membership.invite))