Organize course repos (#4262)

* integrate CoursePlansRepo.translateActivity, translateTopic, translateCoursePlan

* move translation functions to requisite files

* integrate translation endpoint

* refactor: reorganize course-related repos, add request and response model classes

* remove l2s from translation requests

* update translation request and response models, use translation endpoint to get course info, cache courses with L1s in cache key

* update topics repo to use translation endpoint

* use activity translation endpoint

* refactor: incremental loading of individual course info, account for discrepancy between translated IDs and original IDs

* incremental loading of course batches

* Update lib/pangea/course_plans/courses/course_plan_room_extension.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* cleanup

* cleanup

* fix: some name changes

* formatting

---------

Co-authored-by: WilsonLe <leanhminh2907@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com>
This commit is contained in:
ggurdin 2025-10-13 11:36:24 -04:00 committed by GitHub
parent 181c4a369b
commit 261e28abb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 2412 additions and 2666 deletions

View file

@ -5,6 +5,7 @@ import 'package:fluffychat/pangea/common/constants/model_keys.dart';
class ActivityPlanModel {
final String activityId;
final ActivityPlanRequest req;
final String title;
final String description;
@ -66,6 +67,7 @@ class ActivityPlanModel {
);
}
final activityId = json[ModelKey.activityId] ?? json["bookmark_id"];
return ActivityPlanModel(
imageURL: json[ModelKey.activityPlanImageURL],
instructions: json[ModelKey.activityPlanInstructions],
@ -88,7 +90,7 @@ class ActivityPlanModel {
)
: null,
roles: roles,
activityId: json[ModelKey.activityId] ?? json["bookmark_id"],
activityId: activityId,
);
}

View file

@ -18,7 +18,7 @@ import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';

View file

@ -12,11 +12,10 @@ import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart
import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activity_sessions_start_view.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_session_start/bot_join_error_dialog.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/course_plans/activity_summaries_provider.dart';
import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_translation_request.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
@ -59,7 +58,6 @@ class ActivitySessionStartPage extends StatefulWidget {
class ActivitySessionStartController extends State<ActivitySessionStartPage>
with ActivitySummariesProvider {
ActivityPlanModel? activity;
CoursePlanModel? course;
bool loading = true;
Object? error;
@ -264,14 +262,14 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
}
Future<void> _loadActivity() async {
if (courseParent?.coursePlan != null) {
course = await CoursePlansRepo.get(courseParent!.coursePlan!.uuid);
}
final activities = await CourseActivityRepo.get(
final activitiesResponse = await CourseActivityRepo.get(
TranslateActivityRequest(
activityIds: [widget.activityId],
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
widget.activityId,
[widget.activityId],
);
final activities = activitiesResponse.plans.values.toList();
if (activities.isEmpty) {
throw Exception("Activity not found");

View file

@ -11,7 +11,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activ
import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_summary_widget.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_repo.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
@ -94,6 +94,8 @@ class ActivitySessionStartView extends StatelessWidget {
CourseActivityRepo.setSentFeedback(
controller.widget.activityId,
MatrixState.pangeaController.languageController
.activeL1Code()!,
);
await showDialog(

View file

@ -15,8 +15,7 @@ import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart
import 'package:fluffychat/pangea/common/widgets/share_room_button.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_page.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/map_clipper.dart';
import 'package:fluffychat/pangea/course_settings/course_settings.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
@ -204,159 +203,153 @@ class SpaceDetailsContent extends StatelessWidget {
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
return CoursePlanBuilder(
courseId: room.coursePlan?.uuid,
builder: (context, courseController) {
return Column(
mainAxisSize: MainAxisSize.min,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: isColumnMode
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: isColumnMode
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isColumnMode) ...[
ClipPath(
clipper: MapClipper(),
child: Avatar(
mxContent: room.avatar,
name: displayname,
userId: room.directChatMatrixID,
size: 80.0,
borderRadius: BorderRadius.circular(0.0),
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isColumnMode) ...[
ClipPath(
clipper: MapClipper(),
child: Avatar(
mxContent: room.avatar,
name: displayname,
userId: room.directChatMatrixID,
size: 80.0,
borderRadius: BorderRadius.circular(0.0),
),
),
const SizedBox(width: 16.0),
],
Flexible(
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isColumnMode ? 32.0 : 16.0,
fontWeight: isColumnMode
? FontWeight.normal
: FontWeight.bold,
),
),
const SizedBox(width: 16.0),
if (isColumnMode && room.coursePlan != null)
CourseInfoChips(
room.coursePlan!.uuid,
fontSize: 12.0,
iconSize: 12.0,
),
],
Flexible(
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isColumnMode ? 32.0 : 16.0,
fontWeight: isColumnMode
? FontWeight.normal
: FontWeight.bold,
),
),
if (isColumnMode && courseController.course != null)
CourseInfoChips(
courseController.course!,
fontSize: 12.0,
iconSize: 12.0,
),
],
),
),
],
),
),
),
if (room.classCode != null)
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: ShareRoomButton(room: room),
),
],
),
SizedBox(height: isColumnMode ? 24.0 : 12.0),
SpaceDetailsButtonRow(
controller: controller,
room: room,
selectedTab: tab(context),
onTabSelected: (tab) => setSelectedTab(tab, context),
buttons: _buttons(context),
),
SizedBox(height: isColumnMode ? 30.0 : 14.0),
Expanded(
child: Builder(
builder: (context) {
switch (tab(context)) {
case SpaceSettingsTabs.chat:
return CourseChats(
room.id,
activeChat: null,
client: room.client,
);
case SpaceSettingsTabs.course:
return SingleChildScrollView(
child: CourseSettings(
// on redirect back to chat settings after completing activity,
// course settings doesn't refresh activity details by default
// the key forces a rebuild on this redirect
key: ValueKey(controller.widget.activeTab),
courseController,
room: room,
),
);
case SpaceSettingsTabs.participants:
return SingleChildScrollView(
child: RoomParticipantsSection(room: room),
);
case SpaceSettingsTabs.analytics:
return SingleChildScrollView(
child: Center(
child: SpaceAnalytics(roomId: room.id),
),
);
case SpaceSettingsTabs.more:
final buttons = _buttons(context)
.where(
(b) => !b.showInMainView && b.visible,
)
.toList();
return SingleChildScrollView(
child: Column(
children: [
if (room.topic.isNotEmpty) ...[
Text(
room.topic,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
SizedBox(height: isColumnMode ? 30.0 : 14.0),
],
Column(
spacing: 10.0,
mainAxisSize: MainAxisSize.min,
children: buttons.map((b) {
return Opacity(
opacity: b.enabled ? 1.0 : 0.5,
child: ListTile(
title: Text(b.title),
subtitle: b.description != null
? Text(b.description!)
: null,
leading: b.icon,
onTap: b.enabled
? () => b.onPressed?.call()
: null,
),
);
}).toList(),
),
],
),
);
}
},
],
),
),
if (room.classCode != null)
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: ShareRoomButton(room: room),
),
],
);
},
),
SizedBox(height: isColumnMode ? 24.0 : 12.0),
SpaceDetailsButtonRow(
controller: controller,
room: room,
selectedTab: tab(context),
onTabSelected: (tab) => setSelectedTab(tab, context),
buttons: _buttons(context),
),
SizedBox(height: isColumnMode ? 30.0 : 14.0),
Expanded(
child: Builder(
builder: (context) {
switch (tab(context)) {
case SpaceSettingsTabs.chat:
return CourseChats(
room.id,
activeChat: null,
client: room.client,
);
case SpaceSettingsTabs.course:
return SingleChildScrollView(
child: CourseSettings(
// on redirect back to chat settings after completing activity,
// course settings doesn't refresh activity details by default
// the key forces a rebuild on this redirect
key: ValueKey(controller.widget.activeTab),
room: room,
),
);
case SpaceSettingsTabs.participants:
return SingleChildScrollView(
child: RoomParticipantsSection(room: room),
);
case SpaceSettingsTabs.analytics:
return SingleChildScrollView(
child: Center(
child: SpaceAnalytics(roomId: room.id),
),
);
case SpaceSettingsTabs.more:
final buttons = _buttons(context)
.where(
(b) => !b.showInMainView && b.visible,
)
.toList();
return SingleChildScrollView(
child: Column(
children: [
if (room.topic.isNotEmpty) ...[
Text(
room.topic,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
SizedBox(height: isColumnMode ? 30.0 : 14.0),
],
Column(
spacing: 10.0,
mainAxisSize: MainAxisSize.min,
children: buttons.map((b) {
return Opacity(
opacity: b.enabled ? 1.0 : 0.5,
child: ListTile(
title: Text(b.title),
subtitle: b.description != null
? Text(b.description!)
: null,
leading: b.icon,
onTap: b.enabled
? () => b.onPressed?.call()
: null,
),
);
}).toList(),
),
],
),
);
}
},
),
),
],
);
}
}

View file

@ -73,6 +73,14 @@ class PApiUrls {
static String constructSummary =
"${PApiUrls._choreoEndpoint}/construct_summary";
///--------------------------- course translations ---------------------------
static String getLocalizedCourse =
"${PApiUrls._choreoEndpoint}/course_plans/localize";
static String getLocalizedTopic =
"${PApiUrls._choreoEndpoint}/topics/localize";
static String getLocalizedActivity =
"${PApiUrls._choreoEndpoint}/activity_plan/localize";
///-------------------------------- revenue cat --------------------------
static String rcAppsChoreo = "${PApiUrls._subscriptionEndpoint}/app_ids";
static String rcProductsChoreo =

View file

@ -15,8 +15,7 @@ import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_view.dart';
import 'package:fluffychat/pangea/course_chats/extended_space_rooms_chunk.dart';
import 'package:fluffychat/pangea/course_plans/activity_summaries_provider.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
@ -53,8 +52,6 @@ class CourseChatsController extends State<CourseChats>
bool noMoreRooms = false;
bool isLoading = false;
CoursePlanModel? course;
@override
void initState() {
loadHierarchy(reload: true);
@ -93,12 +90,6 @@ class CourseChatsController extends State<CourseChats>
super.dispose();
}
void setCourse(CoursePlanModel? course) {
setState(() {
this.course = course;
});
}
Set<String> get childrenIds =>
room?.spaceChildren.map((c) => c.roomId).whereType<String>().toSet() ??
{};
@ -148,7 +139,6 @@ class CourseChatsController extends State<CourseChats>
sessionsMap[activity]!.add(
ExtendedSpaceRoomsChunk(
chunk: chunk,
activityId: activity.activityId,
userIds: users,
),
);

View file

@ -13,8 +13,6 @@ import 'package:fluffychat/pangea/chat_settings/widgets/chat_context_menu_action
import 'package:fluffychat/pangea/course_chats/activity_template_chat_list_item.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_page.dart';
import 'package:fluffychat/pangea/course_chats/unjoined_chat_list_item.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/space_analytics/analytics_request_indicator.dart';
import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart';
import 'package:fluffychat/utils/stream_extension.dart';
@ -38,211 +36,204 @@ class CourseChatsView extends StatelessWidget {
);
}
return CoursePlanBuilder(
courseId: room.coursePlan?.uuid,
onLoaded: controller.setCourse,
builder: (context, courseController) {
return StreamBuilder(
stream: room.client.onSync.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1)),
builder: (context, snapshot) {
final joinedChats = controller.joinedChats;
final joinedSessions = controller.joinedActivities();
return StreamBuilder(
stream: room.client.onSync.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1)),
builder: (context, snapshot) {
final joinedChats = controller.joinedChats;
final joinedSessions = controller.joinedActivities();
final discoveredGroupChats = controller.discoveredGroupChats;
final discoveredSessions =
controller.discoveredActivities().entries.toList();
final discoveredGroupChats = controller.discoveredGroupChats;
final discoveredSessions =
controller.discoveredActivities().entries.toList();
final isColumnMode = FluffyThemes.isColumnMode(context);
return Padding(
padding: isColumnMode
? const EdgeInsets.symmetric(
vertical: 12.0,
final isColumnMode = FluffyThemes.isColumnMode(context);
return Padding(
padding: isColumnMode
? const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 8.0,
)
: const EdgeInsets.all(0.0),
child: ListView.builder(
shrinkWrap: true,
itemCount: joinedChats.length +
joinedSessions.length +
discoveredGroupChats.length +
discoveredSessions.length +
7,
itemBuilder: (context, i) {
// courses chats title
if (i == 0) {
if (isColumnMode) {
return const Padding(
padding: EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 8.0,
)
: const EdgeInsets.all(0.0),
child: ListView.builder(
shrinkWrap: true,
itemCount: joinedChats.length +
joinedSessions.length +
discoveredGroupChats.length +
discoveredSessions.length +
7,
itemBuilder: (context, i) {
// courses chats title
if (i == 0) {
if (isColumnMode) {
return const Padding(
padding: EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
LearningProgressIndicators(),
Icon(
Icons.chat_bubble_outline,
size: 30.0,
),
SizedBox(height: 12.0),
],
),
);
}
return const SizedBox();
}
i--;
if (i == 0) {
return KnockingUsersIndicator(room: room);
}
i--;
if (i == 0) {
return AnalyticsRequestIndicator(room: room);
}
i--;
// joined group chats
if (i < joinedChats.length) {
final joinedRoom = joinedChats[i];
return ChatListItem(
joinedRoom,
onTap: () => controller.onChatTap(joinedRoom),
onLongPress: (c) => chatContextMenuAction(
joinedRoom,
c,
context,
() => controller.onChatTap(joinedRoom),
),
activeChat: controller.widget.activeChat == joinedRoom.id,
);
}
i -= joinedChats.length;
// unjoined group chats
if (i < discoveredGroupChats.length) {
return UnjoinedChatListItem(
chunk: discoveredGroupChats[i],
onTap: () =>
controller.joinChildRoom(discoveredGroupChats[i]),
);
}
i -= discoveredGroupChats.length;
if (i == 0) {
return joinedSessions.isEmpty && discoveredSessions.isEmpty
? ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(L10n.of(context).whatNow),
subtitle: Text(L10n.of(context).chooseNextActivity),
trailing: const Icon(Icons.arrow_forward),
onTap: () => context.go(
"/rooms/spaces/${room.id}/details?tab=course",
),
)
: const SizedBox();
}
i--;
if (i == 0) {
return joinedSessions.isEmpty
? const SizedBox()
: Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 4.0,
),
child: Text(
L10n.of(context).myActivities,
style: const TextStyle(fontSize: 12.0),
textAlign: TextAlign.center,
),
);
}
i--;
// joined activity sessions
if (i < joinedSessions.length) {
final joinedRoom = joinedSessions[i];
return ChatListItem(
joinedRoom,
onTap: () => controller.onChatTap(joinedRoom),
onLongPress: (c) => chatContextMenuAction(
joinedRoom,
c,
context,
() => controller.onChatTap(joinedRoom),
),
activeChat: controller.widget.activeChat == joinedRoom.id,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
);
}
i -= joinedSessions.length;
if (i == 0) {
return discoveredSessions.isEmpty
? const SizedBox()
: Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 4.0,
),
child: Text(
L10n.of(context).openToJoin,
style: const TextStyle(fontSize: 12.0),
textAlign: TextAlign.center,
),
);
}
i--;
// unjoined activity sessions
if (i < discoveredSessions.length) {
final activity = discoveredSessions[i].key;
final sessions = discoveredSessions[i].value;
return ActivityTemplateChatListItem(
space: room,
activity: activity,
sessions: sessions,
joinActivity: (e) =>
controller.joinActivity(activity.activityId, e),
);
}
i -= discoveredSessions.length;
if (controller.noMoreRooms) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 2.0,
),
child: TextButton(
onPressed: controller.isLoading
? null
: controller.loadHierarchy,
child: controller.isLoading
? LinearProgressIndicator(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
)
: Text(L10n.of(context).loadMore),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
LearningProgressIndicators(),
Icon(
Icons.chat_bubble_outline,
size: 30.0,
),
SizedBox(height: 12.0),
],
),
);
},
),
);
},
}
return const SizedBox();
}
i--;
if (i == 0) {
return KnockingUsersIndicator(room: room);
}
i--;
if (i == 0) {
return AnalyticsRequestIndicator(room: room);
}
i--;
// joined group chats
if (i < joinedChats.length) {
final joinedRoom = joinedChats[i];
return ChatListItem(
joinedRoom,
onTap: () => controller.onChatTap(joinedRoom),
onLongPress: (c) => chatContextMenuAction(
joinedRoom,
c,
context,
() => controller.onChatTap(joinedRoom),
),
activeChat: controller.widget.activeChat == joinedRoom.id,
);
}
i -= joinedChats.length;
// unjoined group chats
if (i < discoveredGroupChats.length) {
return UnjoinedChatListItem(
chunk: discoveredGroupChats[i],
onTap: () =>
controller.joinChildRoom(discoveredGroupChats[i]),
);
}
i -= discoveredGroupChats.length;
if (i == 0) {
return joinedSessions.isEmpty && discoveredSessions.isEmpty
? ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(L10n.of(context).whatNow),
subtitle: Text(L10n.of(context).chooseNextActivity),
trailing: const Icon(Icons.arrow_forward),
onTap: () => context.go(
"/rooms/spaces/${room.id}/details?tab=course",
),
)
: const SizedBox();
}
i--;
if (i == 0) {
return joinedSessions.isEmpty
? const SizedBox()
: Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 4.0,
),
child: Text(
L10n.of(context).myActivities,
style: const TextStyle(fontSize: 12.0),
textAlign: TextAlign.center,
),
);
}
i--;
// joined activity sessions
if (i < joinedSessions.length) {
final joinedRoom = joinedSessions[i];
return ChatListItem(
joinedRoom,
onTap: () => controller.onChatTap(joinedRoom),
onLongPress: (c) => chatContextMenuAction(
joinedRoom,
c,
context,
() => controller.onChatTap(joinedRoom),
),
activeChat: controller.widget.activeChat == joinedRoom.id,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
);
}
i -= joinedSessions.length;
if (i == 0) {
return discoveredSessions.isEmpty
? const SizedBox()
: Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 4.0,
),
child: Text(
L10n.of(context).openToJoin,
style: const TextStyle(fontSize: 12.0),
textAlign: TextAlign.center,
),
);
}
i--;
// unjoined activity sessions
if (i < discoveredSessions.length) {
final activity = discoveredSessions[i].key;
final sessions = discoveredSessions[i].value;
return ActivityTemplateChatListItem(
space: room,
activity: activity,
sessions: sessions,
joinActivity: (e) =>
controller.joinActivity(activity.activityId, e),
);
}
i -= discoveredSessions.length;
if (controller.noMoreRooms) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 2.0,
),
child: TextButton(
onPressed:
controller.isLoading ? null : controller.loadHierarchy,
child: controller.isLoading
? LinearProgressIndicator(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
)
: Text(L10n.of(context).loadMore),
),
);
},
),
);
},
);

View file

@ -2,12 +2,10 @@ import 'package:matrix/matrix.dart';
class ExtendedSpaceRoomsChunk {
final SpaceRoomsChunk chunk;
final String activityId;
final List<String> userIds;
ExtendedSpaceRoomsChunk({
required this.chunk,
required this.activityId,
required this.userIds,
});
}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
class CourseInfoChip extends StatelessWidget {
@ -45,22 +45,46 @@ class CourseInfoChip extends StatelessWidget {
}
}
class CourseInfoChips extends StatelessWidget {
final CoursePlanModel course;
class CourseInfoChips extends StatefulWidget {
final String courseId;
final double? fontSize;
final double? iconSize;
final EdgeInsets? padding;
const CourseInfoChips(
this.course, {
this.courseId, {
super.key,
this.fontSize,
this.iconSize,
this.padding,
});
@override
State<CourseInfoChips> createState() => CourseInfoChipsState();
}
class CourseInfoChipsState extends State<CourseInfoChips>
with CoursePlanProvider {
@override
void initState() {
super.initState();
loadCourse(widget.courseId);
}
@override
void didUpdateWidget(covariant CourseInfoChips oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.courseId != widget.courseId) {
loadCourse(widget.courseId);
}
}
@override
Widget build(BuildContext context) {
if (course == null) {
return const SizedBox.shrink();
}
return Wrap(
spacing: 8.0,
runSpacing: 8.0,
@ -69,31 +93,24 @@ class CourseInfoChips extends StatelessWidget {
CourseInfoChip(
icon: Icons.language,
text:
"${course.baseLanguageDisplay}${course.targetLanguageDisplay}",
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
"${course!.baseLanguageDisplay}${course!.targetLanguageDisplay}",
fontSize: widget.fontSize,
iconSize: widget.iconSize,
padding: widget.padding,
),
CourseInfoChip(
icon: Icons.school,
text: course.cefrLevel.string,
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
text: course!.cefrLevel.string,
fontSize: widget.fontSize,
iconSize: widget.iconSize,
padding: widget.padding,
),
CourseInfoChip(
icon: Icons.location_on,
text: L10n.of(context).numModules(course.topicIds.length),
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
),
CourseInfoChip(
icon: Icons.event_note_outlined,
text: L10n.of(context).numActivityPlans(course.totalActivities),
fontSize: fontSize,
iconSize: iconSize,
padding: padding,
text: L10n.of(context).numModules(course!.topicIds.length),
fontSize: widget.fontSize,
iconSize: widget.iconSize,
padding: widget.padding,
),
],
);

View file

@ -10,7 +10,7 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -29,7 +29,22 @@ class CourseInvitePage extends StatefulWidget {
CourseInvitePageController createState() => CourseInvitePageController();
}
class CourseInvitePageController extends State<CourseInvitePage> {
class CourseInvitePageController extends State<CourseInvitePage>
with CoursePlanProvider {
@override
void initState() {
super.initState();
loadCourse(widget.courseId);
}
@override
void didUpdateWidget(covariant CourseInvitePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.courseId != widget.courseId) {
loadCourse(widget.courseId);
}
}
Future<String> getSpaceId() async {
if (widget.courseCreationCompleter == null) {
throw Exception("No course creation completer provided");
@ -44,184 +59,176 @@ class CourseInvitePageController extends State<CourseInvitePage> {
final theme = Theme.of(context);
final client = Matrix.of(context).client;
return CoursePlanBuilder(
courseId: widget.courseId,
builder: (context, courseController) {
return Scaffold(
body: SafeArea(
child: Center(
child: Container(
padding: const EdgeInsets.all(30.0),
constraints: const BoxConstraints(
maxWidth: 750,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
courseController.course != null
? Container(
decoration: BoxDecoration(
border: Border.all(color: AppConfig.gold),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16.0),
child: Column(
spacing: 16.0,
mainAxisSize: MainAxisSize.min,
return Scaffold(
body: SafeArea(
child: Center(
child: Container(
padding: const EdgeInsets.all(30.0),
constraints: const BoxConstraints(
maxWidth: 750,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
course != null
? Container(
decoration: BoxDecoration(
border: Border.all(color: AppConfig.gold),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16.0),
child: Column(
spacing: 16.0,
mainAxisSize: MainAxisSize.min,
children: [
Row(
spacing: 10.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
spacing: 10.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.map_outlined,
size: 40.0,
),
Flexible(
child: Text(
courseController.course!.title,
style: theme.textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
const Icon(
Icons.map_outlined,
size: 40.0,
),
CourseInfoChips(
courseController.course!,
fontSize: 12.0,
iconSize: 12.0,
Flexible(
child: Text(
course!.title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
)
: const CircularProgressIndicator.adaptive(),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
spacing: 16.0,
mainAxisSize: MainAxisSize.min,
children: [
LayoutBuilder(
builder: (context, constraints) {
const avatarSpace = avatarSize + 8.0;
final availableSpace =
constraints.maxWidth - 24.0;
CourseInfoChips(
widget.courseId,
fontSize: 12.0,
iconSize: 12.0,
),
],
),
)
: loadingCourse
? const CircularProgressIndicator.adaptive()
: const SizedBox(),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
spacing: 16.0,
mainAxisSize: MainAxisSize.min,
children: [
LayoutBuilder(
builder: (context, constraints) {
const avatarSpace = avatarSize + 8.0;
final availableSpace = constraints.maxWidth - 24.0;
final visibleAvatars = min(
3,
(availableSpace / avatarSpace).floor() - 2,
);
final visibleAvatars = min(
3,
(availableSpace / avatarSpace).floor() - 2,
);
return Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder(
future: client
.getProfileFromUserId(client.userID!),
builder: (context, snapshot) {
return Avatar(
size: avatarSize,
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
client.userID!.localpart,
userId: client.userID!,
);
},
),
Avatar(
userId: BotName.byEnvironment,
return Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder(
future:
client.getProfileFromUserId(client.userID!),
builder: (context, snapshot) {
return Avatar(
size: avatarSize,
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
client.userID!.localpart,
userId: client.userID!,
);
},
),
Avatar(
userId: BotName.byEnvironment,
size: avatarSize,
),
...List.generate(visibleAvatars, (index) {
return CircleAvatar(
radius: avatarSize / 2,
backgroundColor: AppConfig.gold.withAlpha(80),
child: const Icon(
Icons.question_mark,
size: 20.0,
),
...List.generate(visibleAvatars, (index) {
return CircleAvatar(
radius: avatarSize / 2,
backgroundColor:
AppConfig.gold.withAlpha(80),
child: const Icon(
Icons.question_mark,
size: 20.0,
),
);
}),
const Icon(
Icons.more_horiz,
size: 24.0,
),
],
);
},
),
Text(
L10n.of(context).courseStartDesc,
style: theme.textTheme.titleMedium,
),
);
}),
const Icon(
Icons.more_horiz,
size: 24.0,
),
],
);
},
),
Text(
L10n.of(context).courseStartDesc,
style: theme.textTheme.titleMedium,
),
],
),
),
Column(
spacing: 24.0,
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: () async {
final resp = await showFutureLoadingDialog(
context: context,
future: getSpaceId,
);
if (mounted && !resp.isError) {
context.go("/rooms/spaces/${resp.result}/invite");
}
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
),
child: Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file),
Text(L10n.of(context).inviteYourFriends),
],
),
),
Column(
spacing: 24.0,
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: () async {
final resp = await showFutureLoadingDialog(
context: context,
future: getSpaceId,
);
if (mounted && !resp.isError) {
context.go("/rooms/spaces/${resp.result}/invite");
}
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor:
theme.colorScheme.onPrimaryContainer,
),
child: Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file),
Text(L10n.of(context).inviteYourFriends),
],
),
),
ElevatedButton(
onPressed: () async {
final resp = await showFutureLoadingDialog(
context: context,
future: getSpaceId,
);
if (mounted && !resp.isError) {
context.go("/rooms/spaces/${resp.result}");
}
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor:
theme.colorScheme.onPrimaryContainer,
),
child: Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(L10n.of(context).playWithAI),
],
),
),
],
ElevatedButton(
onPressed: () async {
final resp = await showFutureLoadingDialog(
context: context,
future: getSpaceId,
);
if (mounted && !resp.isError) {
context.go("/rooms/spaces/${resp.result}");
}
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
),
child: Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(L10n.of(context).playWithAI),
],
),
),
],
),
),
],
),
),
);
},
),
),
);
}
}

View file

@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/map_clipper.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
class CoursePlanTile extends StatelessWidget {
final String courseId;
final CoursePlanModel course;
final VoidCallback onTap;
@ -18,6 +19,7 @@ class CoursePlanTile extends StatelessWidget {
const CoursePlanTile({
super.key,
required this.courseId,
required this.course,
required this.onTap,
this.titleFontSize,
@ -72,7 +74,7 @@ class CoursePlanTile extends StatelessWidget {
),
),
CourseInfoChips(
course,
courseId,
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 2.0,

View file

@ -1,14 +1,15 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_filter.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
mixin CourseSearchProvider<T extends StatefulWidget> on State<T> {
bool loading = true;
Object? error;
List<CoursePlanModel> courses = [];
Map<String, CoursePlanModel> courses = {};
LanguageModel? targetLanguageFilter;
@override
@ -36,7 +37,8 @@ mixin CourseSearchProvider<T extends StatefulWidget> on State<T> {
loading = true;
error = null;
});
courses = await CoursePlansRepo.searchByFilter(filter: _filter);
final resp = await CoursePlansRepo.searchByFilter(filter: _filter);
courses = resp.coursePlans;
} catch (e, s) {
debugPrint("Failed to load courses: $e\n$s");
error = e;

View file

@ -8,8 +8,9 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/course_creation/selected_course_view.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/spaces/utils/client_spaces_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -39,7 +40,22 @@ class SelectedCourse extends StatefulWidget {
SelectedCourseController createState() => SelectedCourseController();
}
class SelectedCourseController extends State<SelectedCourse> {
class SelectedCourseController extends State<SelectedCourse>
with CoursePlanProvider {
@override
initState() {
super.initState();
loadCourse(widget.courseId).then((_) => loadTopics());
}
@override
void didUpdateWidget(covariant SelectedCourse oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.courseId != widget.courseId) {
loadCourse(widget.courseId).then((_) => loadTopics());
}
}
String get title {
switch (widget.mode) {
case SelectedCourseMode.launch:
@ -65,15 +81,18 @@ class SelectedCourseController extends State<SelectedCourse> {
Future<void> submit(CoursePlanModel course) async {
switch (widget.mode) {
case SelectedCourseMode.launch:
return launchCourse(course);
return launchCourse(widget.courseId, course);
case SelectedCourseMode.addToSpace:
return addCourseToSpace(course);
case SelectedCourseMode.join:
return joinCourse(course);
return joinCourse();
}
}
Future<void> launchCourse(CoursePlanModel course) async {
Future<void> launchCourse(
String courseId,
CoursePlanModel course,
) async {
final client = Matrix.of(context).client;
final Completer<String> completer = Completer<String>();
client
@ -88,7 +107,7 @@ class SelectedCourseController extends State<SelectedCourse> {
sdk.StateEvent(
type: PangeaEventTypes.coursePlan,
content: {
"uuid": course.uuid,
"uuid": courseId,
},
),
],
@ -129,7 +148,7 @@ class SelectedCourseController extends State<SelectedCourse> {
context.go("/rooms/spaces/${space.id}/details");
}
Future<void> joinCourse(CoursePlanModel course) async {
Future<void> joinCourse() async {
if (widget.roomChunk == null) {
throw Exception("Room chunk is null");
}

View file

@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_creation/selected_course_page.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/map_clipper.dart';
import 'package:fluffychat/pangea/course_settings/pin_clipper.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
@ -29,6 +27,7 @@ class SelectedCourseView extends StatelessWidget {
const double mediumIconSize = 16.0;
const double smallIconSize = 12.0;
final course = controller.course;
return Scaffold(
appBar: AppBar(
title: Text(
@ -36,17 +35,18 @@ class SelectedCourseView extends StatelessWidget {
),
),
body: SafeArea(
child: CoursePlanBuilder(
courseId: controller.widget.courseId,
onNotFound: () => context.go("/rooms/course/own"),
builder: (context, courseController) {
final course = courseController.course;
return Container(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500.0),
child: course == null
? const Center(child: CircularProgressIndicator.adaptive())
child: Container(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500.0),
child: controller.loadingCourse
? const Center(child: CircularProgressIndicator.adaptive())
: controller.courseError != null || course == null
? Center(
child: ErrorIndicator(
message: L10n.of(context).oopsSomethingWentWrong,
),
)
: Column(
children: [
Expanded(
@ -57,7 +57,7 @@ class SelectedCourseView extends StatelessWidget {
right: 12.0,
),
child: ListView.builder(
itemCount: course.loadedTopics.length + 2,
itemCount: course.topicIds.length + 2,
itemBuilder: (context, index) {
String displayname = course.title;
final roomChunk = controller.widget.roomChunk;
@ -104,7 +104,7 @@ class SelectedCourseView extends StatelessWidget {
),
),
CourseInfoChips(
course,
controller.widget.courseId,
fontSize: descFontSize,
iconSize: smallIconSize,
),
@ -135,11 +135,17 @@ class SelectedCourseView extends StatelessWidget {
index--;
if (index >= course.loadedTopics.length) {
if (index >= course.topicIds.length) {
return const SizedBox(height: 12.0);
}
final topic = course.loadedTopics[index];
final topicId = course.topicIds[index];
final topic = course.loadedTopics[topicId];
if (topic == null) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
@ -201,17 +207,6 @@ class SelectedCourseView extends StatelessWidget {
fontSize: descFontSize,
iconSize: smallIconSize,
),
CourseInfoChip(
icon: Icons
.event_note_outlined,
text: L10n.of(context)
.numActivityPlans(
topic.loadedActivities
.length,
),
fontSize: descFontSize,
iconSize: smallIconSize,
),
],
),
),
@ -307,9 +302,7 @@ class SelectedCourseView extends StatelessWidget {
),
],
),
),
);
},
),
),
),
);

View file

@ -4,8 +4,8 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat_settings/utils/room_summary_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
@ -56,6 +56,7 @@ mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
),
)
.map((e) => e.activityPlan.activityId)
.whereType<String>()
.toSet();
}
@ -70,20 +71,11 @@ mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
bool _hasCompletedTopic(
String userID,
CourseTopicModel topic,
CoursePlanModel course,
) {
final topicIndex = course.loadedTopics.indexWhere(
(t) => t.uuid == topic.uuid,
);
if (topicIndex == -1) {
throw Exception('Topic not found');
}
final topicActivities = course.loadedTopics[topicIndex].loadedActivities;
final topicActivityIds = topicActivities.map((a) => a.activityId).toSet();
final numTwoPersonActivities =
topicActivities.where((a) => a.req.numberOfParticipants <= 2).length;
final topicActivityIds = topic.activityIds.toSet();
final numTwoPersonActivities = topic.loadedActivities.values
.where((a) => a.req.numberOfParticipants <= 2)
.length;
final completedTopicActivities =
_completedActivities(userID).intersection(topicActivityIds);
@ -91,36 +83,40 @@ mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
return completedTopicActivities.length >= numTwoPersonActivities;
}
int currentTopicIndex(
String? currentTopicId(
String userID,
CoursePlanModel course,
) {
if (course.loadedTopics.isEmpty) return -1;
for (int i = 0; i < course.loadedTopics.length; i++) {
if (!_hasCompletedTopic(userID, course.loadedTopics[i], course)) {
return i;
if (course.loadedTopics.isEmpty) {
return null;
}
for (int i = 0; i < course.topicIds.length; i++) {
final topicId = course.topicIds[i];
final topic = course.loadedTopics[topicId];
if (topic == null) continue;
if (!topic.activityListComplete) {
return null;
}
if (!_hasCompletedTopic(userID, topic) && topic.activityIds.isNotEmpty) {
return topicId;
}
}
return 0;
return course.topicIds.last;
}
Future<Map<String, List<User>>> topicsToUsers(
Map<String, List<User>> topicsToUsers(
Room room,
CoursePlanModel course,
) async {
) {
final Map<String, List<User>> topicUserMap = {};
final users = await room.requestParticipants(
[Membership.join, Membership.invite, Membership.knock],
false,
true,
);
final users = room.getParticipants();
for (final user in users) {
if (user.id == BotName.byEnvironment) continue;
final topicIndex = currentTopicIndex(user.id, course);
if (topicIndex != -1) {
final topicID = course.loadedTopics[topicIndex].uuid;
topicUserMap.putIfAbsent(topicID, () => []).add(user);
final topicId = currentTopicId(user.id, course);
if (topicId != null) {
topicUserMap.putIfAbsent(topicId, () => []).add(user);
}
}
return topicUserMap;

View file

@ -0,0 +1,168 @@
import 'dart:async';
import 'dart:convert';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_translation_request.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_translation_response.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseActivityRepo {
static final Map<String, Completer<TranslateActivityResponse>> _cache = {};
static final GetStorage _storage = GetStorage('course_activity_storage');
static Future<TranslateActivityResponse> get(
TranslateActivityRequest request,
String batchId,
) async {
await _storage.initStorage;
final activities = getCached(request).plans;
final toFetch =
request.activityIds.where((id) => !activities.containsKey(id)).toList();
if (toFetch.isNotEmpty) {
final fetchedActivities = await _fetch(request, batchId);
activities.addAll(fetchedActivities.plans);
await _setCached(fetchedActivities, request.l1);
}
return TranslateActivityResponse(plans: activities);
}
static Future<TranslateActivityResponse> translate(
TranslateActivityRequest request,
) async {
final Requests req = Requests(
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res = await req.post(
url: PApiUrls.getLocalizedActivity,
body: request.toJson(),
);
if (res.statusCode != 200) {
throw Exception(
"Failed to translate activity: ${res.statusCode} ${res.body}",
);
}
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = TranslateActivityResponse.fromJson(decodedBody);
return response;
}
static Future<TranslateActivityResponse> _fetch(
TranslateActivityRequest request,
String batchId,
) async {
if (_cache.containsKey(batchId)) {
return _cache[batchId]!.future;
}
final completer = Completer<TranslateActivityResponse>();
_cache[batchId] = completer;
try {
final response = await translate(request);
completer.complete(response);
return response;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(batchId);
}
}
static TranslateActivityResponse getCached(
TranslateActivityRequest request,
) {
final Map<String, ActivityPlanModel> activities = {};
for (final id in request.activityIds) {
final cacheKey = "${id}_${request.l1}";
final sentActivityFeedback = sentFeedback[cacheKey];
if (sentActivityFeedback != null &&
DateTime.now().difference(sentActivityFeedback) >
const Duration(minutes: 15)) {
_storage.remove(cacheKey);
_clearSentFeedback(cacheKey, request.l1);
continue;
}
final json = _storage.read<Map<String, dynamic>>(cacheKey);
if (json != null) {
try {
final activity = ActivityPlanModel.fromJson(json);
activities[id] = activity;
} catch (e) {
// ignore invalid cached data
_storage.remove(cacheKey);
}
}
}
return TranslateActivityResponse(plans: activities);
}
static Future<void> _setCached(
TranslateActivityResponse activities,
String l1,
) async {
final List<Future> futures = [];
for (final entry in activities.plans.entries) {
final cacheKey = "${entry.key}_$l1";
futures.add(_storage.write(cacheKey, entry.value.toJson()));
}
await Future.wait(futures);
}
static Future<void> clearCache() async {
await _storage.erase();
}
static Map<String, DateTime> get sentFeedback {
final entry = _storage.read("sent_feedback");
if (entry != null && entry is Map<String, dynamic>) {
try {
return Map<String, DateTime>.from(
entry.map((key, value) => MapEntry(key, DateTime.parse(value))),
);
} catch (e) {
_storage.remove("sent_feedback");
}
}
return {};
}
static Future<void> setSentFeedback(
String activityId,
String l1,
) async {
final currentValue = sentFeedback;
currentValue["${activityId}_$l1"] = DateTime.now();
await _storage.write(
"sent_feedback",
currentValue.map((key, value) => MapEntry(key, value.toIso8601String())),
);
}
static Future<void> _clearSentFeedback(
String activityId,
String l1,
) async {
final currentValue = sentFeedback;
currentValue.remove("${activityId}_$l1");
await _storage.write(
"sent_feedback",
currentValue.map((key, value) => MapEntry(key, value.toIso8601String())),
);
}
}

View file

@ -0,0 +1,23 @@
class TranslateActivityRequest {
List<String> activityIds;
String l1;
TranslateActivityRequest({
required this.activityIds,
required this.l1,
});
Map<String, dynamic> toJson() => {
"activity_ids": activityIds,
"l1": l1,
};
factory TranslateActivityRequest.fromJson(Map<String, dynamic> json) {
return TranslateActivityRequest(
activityIds: json['activity_ids'] != null
? List<String>.from(json['activity_ids'])
: [],
l1: json['l1'],
);
}
}

View file

@ -0,0 +1,25 @@
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
class TranslateActivityResponse {
final Map<String, ActivityPlanModel> plans;
TranslateActivityResponse({required this.plans});
factory TranslateActivityResponse.fromJson(Map<String, dynamic> json) {
final plansEntry = json['plans'] as Map<String, dynamic>;
return TranslateActivityResponse(
plans: plansEntry.map(
(key, value) {
return MapEntry(
key,
ActivityPlanModel.fromJson(value),
);
},
),
);
}
Map<String, dynamic> toJson() => {
"plans": plans.map((key, value) => MapEntry(key, value.toJson())),
};
}

View file

@ -1,207 +0,0 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_activity.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_activity_media.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseActivityRepo {
static final Map<String, Completer<List<ActivityPlanModel>>> _cache = {};
static final GetStorage _storage = GetStorage('course_activity_storage');
static Map<String, DateTime> get sentFeedback {
final entry = _storage.read("sent_feedback");
if (entry != null && entry is Map<String, dynamic>) {
try {
return Map<String, DateTime>.from(
entry.map((key, value) => MapEntry(key, DateTime.parse(value))),
);
} catch (e) {
_storage.remove("sent_feedback");
}
}
return {};
}
static Future<void> setSentFeedback(String activityId) async {
final currentValue = sentFeedback;
currentValue[activityId] = DateTime.now();
await _storage.write(
"sent_feedback",
currentValue.map((key, value) => MapEntry(key, value.toIso8601String())),
);
}
static Future<void> _clearSentFeedback(String activityId) async {
final currentValue = sentFeedback;
currentValue.remove(activityId);
await _storage.write(
"sent_feedback",
currentValue.map((key, value) => MapEntry(key, value.toIso8601String())),
);
}
static ActivityPlanModel? _getCached(String uuid) {
final sentActivityFeedback = sentFeedback[uuid];
if (sentActivityFeedback != null &&
DateTime.now().difference(sentActivityFeedback) >
const Duration(minutes: 15)) {
_storage.remove(uuid);
_clearSentFeedback(uuid);
return null;
}
final json = _storage.read<Map<String, dynamic>>(uuid);
if (json != null) {
try {
return ActivityPlanModel.fromJson(json);
} catch (e) {
// ignore invalid cached data
_storage.remove(uuid);
}
}
return null;
}
static Future<void> _setCached(ActivityPlanModel activity) =>
_storage.write(activity.activityId, activity.toJson());
static List<ActivityPlanModel> getSync(List<String> uuids) {
return uuids
.map((uuid) => _getCached(uuid))
.whereType<ActivityPlanModel>()
.toList();
}
static Future<List<ActivityPlanModel>> get(
String topicId,
List<String> uuids,
) async {
final activities = <ActivityPlanModel>[];
final toFetch = <String>[];
await _storage.initStorage;
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
activities.add(cached);
} else {
toFetch.add(uuid);
}
}
if (toFetch.isNotEmpty) {
final fetchedActivities = await _fetch(topicId, toFetch);
activities.addAll(fetchedActivities);
for (final activity in fetchedActivities) {
await _setCached(activity);
}
}
return activities;
}
static Future<List<ActivityPlanModel>> _fetch(
String topicId,
List<String> uuids,
) async {
if (_cache.containsKey(topicId)) {
return _cache[topicId]!.future;
}
final completer = Completer<List<ActivityPlanModel>>();
_cache[topicId] = completer;
final where = {
"id": {"in": uuids.join(",")},
};
final limit = uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanActivitiesResult = await payload.find(
CmsCoursePlanActivity.slug,
CmsCoursePlanActivity.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
final imageUrls = await _fetchImageUrls(
cmsCoursePlanActivitiesResult.docs,
);
final activities = cmsCoursePlanActivitiesResult.docs
.map((a) => a.toActivityPlanModel(imageUrls[a.id]))
.toList();
completer.complete(activities);
return activities;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(topicId);
}
}
static Future<Map<String, String>> _fetchImageUrls(
List<CmsCoursePlanActivity> activities,
) async {
// map of mediaId to activityId
final activityToMediaId = Map.fromEntries(
activities
.where((a) => a.coursePlanActivityMedia?.docs?.isNotEmpty ?? false)
.map((a) {
final mediaIds = a.coursePlanActivityMedia?.docs;
return MapEntry(mediaIds?.firstOrNull, a.id);
}),
);
final mediaIds = activityToMediaId.keys.whereType<String>().toList();
if (mediaIds.isEmpty) {
return {};
}
final where = {
"id": {"in": mediaIds.join(",")},
};
final limit = mediaIds.length;
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanActivityMediasResult = await payload.find(
CmsCoursePlanActivityMedia.slug,
CmsCoursePlanActivityMedia.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
return Map.fromEntries(
cmsCoursePlanActivityMediasResult.docs.map((media) {
final activityId = activityToMediaId[media.id];
if (activityId != null && media.url != null) {
return MapEntry(activityId, '${Environment.cmsApi}${media.url!}');
}
return null;
}).whereType<MapEntry<String, String>>(),
);
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -0,0 +1,9 @@
class CourseInfoBatchRequest {
final String batchId;
final List<String> uuids;
CourseInfoBatchRequest({
required this.batchId,
required this.uuids,
});
}

View file

@ -1,111 +0,0 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_topic_location_media.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseLocationMediaRepo {
static final Map<String, Completer<Map<String, String>>> _cache = {};
static final GetStorage _storage =
GetStorage('course_location_media_storage');
static String? _getCached(String uuid) {
try {
return _storage.read(uuid) as String?;
} catch (e) {
// If parsing fails, remove the corrupted cache entry
_storage.remove(uuid);
}
return null;
}
static Future<void> _setCached(String uuid, String url) =>
_storage.write(uuid, url);
static List<String> getSync(List<String> uuids) {
final urls = <String>[];
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
urls.add(cached);
}
}
return urls;
}
static Future<List<String>> get(String topicId, List<String> uuids) async {
final urls = <String>[];
final toFetch = <String>[];
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
urls.add(cached);
} else {
toFetch.add(uuid);
}
}
if (toFetch.isNotEmpty) {
final fetched = await _fetch(topicId, toFetch);
urls.addAll(fetched.values);
for (final entry in fetched.entries) {
await _setCached(entry.key, entry.value);
}
}
return urls;
}
static Future<Map<String, String>> _fetch(
String topicId,
List<String> uuids,
) async {
if (_cache.containsKey(topicId)) {
return _cache[topicId]!.future;
}
final completer = Completer<Map<String, String>>();
_cache[topicId] = completer;
final where = {
"id": {"in": uuids.join(",")},
};
final limit = uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanTopicLocationMediasResult = await payload.find(
CmsCoursePlanTopicLocationMedia.slug,
CmsCoursePlanTopicLocationMedia.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
final media = Map.fromEntries(
cmsCoursePlanTopicLocationMediasResult.docs
.map((e) => MapEntry(e.id, e.url!)),
);
completer.complete(media);
return media;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(topicId);
}
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -1,119 +0,0 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_location_model.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_topic_location.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseLocationRepo {
static final Map<String, Completer<List<CourseLocationModel>>> _cache = {};
static final GetStorage _storage = GetStorage('course_location_storage');
static CourseLocationModel? _getCached(String uuid) {
final json = _storage.read(uuid);
if (json != null) {
try {
return CourseLocationModel.fromJson(Map<String, dynamic>.from(json));
} catch (e) {
// If parsing fails, remove the corrupted cache entry
_storage.remove(uuid);
}
}
return null;
}
static Future<void> _setCached(String uuid, CourseLocationModel location) =>
_storage.write(uuid, location.toJson());
static List<CourseLocationModel> getSync(List<String> uuids) {
final locations = <CourseLocationModel>[];
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
locations.add(cached);
}
}
return locations;
}
static Future<List<CourseLocationModel>> get(
String courseId,
List<String> uuids,
) async {
final locations = <CourseLocationModel>[];
final toFetch = <String>[];
await _storage.initStorage;
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
locations.add(cached);
} else {
toFetch.add(uuid);
}
}
if (toFetch.isNotEmpty) {
final fetchedLocations = await _fetch(courseId, toFetch);
locations.addAll(fetchedLocations);
for (int i = 0; i < fetchedLocations.length; i++) {
final location = fetchedLocations[i];
await _setCached(location.uuid, location);
}
}
return locations;
}
static Future<List<CourseLocationModel>> _fetch(
String topicId,
List<String> uuids,
) async {
if (_cache.containsKey(topicId)) {
return _cache[topicId]!.future;
}
final completer = Completer<List<CourseLocationModel>>();
_cache[topicId] = completer;
final where = {
"id": {"in": uuids.join(",")},
};
final limit = uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanTopicLocationsResult = await payload.find(
CmsCoursePlanTopicLocation.slug,
CmsCoursePlanTopicLocation.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
final locations = cmsCoursePlanTopicLocationsResult.docs
.map((location) => location.toCourseLocationModel())
.toList();
completer.complete(locations);
return locations;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(topicId);
}
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -0,0 +1,119 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_info_batch_request.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_media_response.dart';
import 'package:fluffychat/pangea/course_plans/course_media/course_media_info.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_topic_location_media.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseLocationMediaRepo {
static final Map<String, Completer<CourseLocationMediaResponse>> _cache = {};
static final GetStorage _storage =
GetStorage('course_location_media_storage');
static Future<CourseLocationMediaResponse> get(
CourseInfoBatchRequest request,
) async {
await _storage.initStorage;
final cached = getCached(request);
final urls = List<CourseMediaInfo>.from(cached.mediaUrls);
final toFetch = request.uuids
.where((uuid) => !cached.mediaUrls.any((e) => e.uuid == uuid))
.toList();
if (toFetch.isNotEmpty) {
final fetched = await _fetch(
CourseInfoBatchRequest(
batchId: request.batchId,
uuids: toFetch,
),
);
urls.addAll(fetched.mediaUrls);
await _setCached(fetched);
}
return CourseLocationMediaResponse(mediaUrls: urls);
}
static Future<CourseLocationMediaResponse> _fetch(
CourseInfoBatchRequest request,
) async {
if (_cache.containsKey(request.batchId)) {
return _cache[request.batchId]!.future;
}
final completer = Completer<CourseLocationMediaResponse>();
_cache[request.batchId] = completer;
final where = {
"id": {"in": request.uuids.join(",")},
};
final limit = request.uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanTopicLocationMediasResult = await payload.find(
CmsCoursePlanTopicLocationMedia.slug,
CmsCoursePlanTopicLocationMedia.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
final resp = CourseLocationMediaResponse.fromCmsResponse(
cmsCoursePlanTopicLocationMediasResult,
);
completer.complete(resp);
return resp;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(request.batchId);
}
}
static CourseLocationMediaResponse getCached(
CourseInfoBatchRequest request,
) {
final List<CourseMediaInfo> urls = [];
for (final uuid in request.uuids) {
try {
final url = _storage.read(uuid) as String?;
if (url != null) {
urls.add(
CourseMediaInfo(
uuid: uuid,
url: url,
),
);
}
} catch (e) {
// If parsing fails, remove the corrupted cache entry
_storage.remove(uuid);
}
}
return CourseLocationMediaResponse(mediaUrls: urls);
}
static Future<void> _setCached(CourseLocationMediaResponse response) async {
final List<Future> futures = [];
for (final entry in response.mediaUrls) {
futures.add(_storage.write(entry.uuid, entry.url));
}
await Future.wait(futures);
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -0,0 +1,28 @@
import 'package:fluffychat/pangea/course_plans/course_media/course_media_info.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_topic_location_media.dart';
import 'package:fluffychat/pangea/payload_client/paginated_response.dart';
class CourseLocationMediaResponse {
final List<CourseMediaInfo> mediaUrls;
CourseLocationMediaResponse({
required this.mediaUrls,
});
factory CourseLocationMediaResponse.fromCmsResponse(
PayloadPaginatedResponse<CmsCoursePlanTopicLocationMedia>
cmsCoursePlanTopicLocationMediasResult,
) {
return CourseLocationMediaResponse(
mediaUrls: cmsCoursePlanTopicLocationMediasResult.docs
.where((e) => e.url != null)
.map(
(e) => CourseMediaInfo(
uuid: e.id,
url: e.url!,
),
)
.toList(),
);
}
}

View file

@ -0,0 +1,118 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_info_batch_request.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_model.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_response.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_topic_location.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseLocationRepo {
static final Map<String, Completer<CourseLocationResponse>> _cache = {};
static final GetStorage _storage = GetStorage('course_location_storage');
static Future<CourseLocationResponse> get(
CourseInfoBatchRequest request,
) async {
await _storage.initStorage;
final locations = getCached(request).locations;
final toFetch = request.uuids
.where(
(id) => locations.indexWhere((location) => location.uuid == id) == -1,
)
.toList();
if (toFetch.isNotEmpty) {
final fetchedLocations = await _fetch(
CourseInfoBatchRequest(
batchId: request.batchId,
uuids: toFetch,
),
);
locations.addAll(fetchedLocations.locations);
await _setCached(fetchedLocations);
}
return CourseLocationResponse(locations: locations);
}
static Future<CourseLocationResponse> _fetch(
CourseInfoBatchRequest request,
) async {
if (_cache.containsKey(request.batchId)) {
return _cache[request.batchId]!.future;
}
final completer = Completer<CourseLocationResponse>();
_cache[request.batchId] = completer;
final where = {
"id": {"in": request.uuids.join(",")},
};
final limit = request.uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanTopicLocationsResult = await payload.find(
CmsCoursePlanTopicLocation.slug,
CmsCoursePlanTopicLocation.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
final response = CourseLocationResponse.fromCmsResponse(
cmsCoursePlanTopicLocationsResult,
);
completer.complete(response);
return response;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(request.batchId);
}
}
static CourseLocationResponse getCached(
CourseInfoBatchRequest request,
) {
final List<CourseLocationModel> locations = [];
for (final uuid in request.uuids) {
final json = _storage.read(uuid);
if (json != null) {
try {
final location =
CourseLocationModel.fromJson(Map<String, dynamic>.from(json));
locations.add(location);
} catch (e) {
// If parsing fails, remove the corrupted cache entry
_storage.remove(uuid);
}
}
}
return CourseLocationResponse(locations: locations);
}
static Future<void> _setCached(CourseLocationResponse locations) {
final List<Future> futures = [];
for (final location in locations.locations) {
futures.add(_storage.write(location.uuid, location.toJson()));
}
return Future.wait(futures);
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -0,0 +1,21 @@
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_model.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_topic_location.dart';
import 'package:fluffychat/pangea/payload_client/paginated_response.dart';
class CourseLocationResponse {
final List<CourseLocationModel> locations;
CourseLocationResponse({
required this.locations,
});
factory CourseLocationResponse.fromCmsResponse(
PayloadPaginatedResponse<CmsCoursePlanTopicLocation> response,
) {
final locations = response.docs
.map((location) => location.toCourseLocationModel())
.toList();
return CourseLocationResponse(locations: locations);
}
}

View file

@ -0,0 +1,9 @@
class CourseMediaInfo {
final String uuid;
final String url;
CourseMediaInfo({
required this.uuid,
required this.url,
});
}

View file

@ -0,0 +1,119 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_info_batch_request.dart';
import 'package:fluffychat/pangea/course_plans/course_media/course_media_info.dart';
import 'package:fluffychat/pangea/course_plans/course_media/course_media_response.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_media.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseMediaRepo {
static final Map<String, Completer<CourseMediaResponse>> _cache = {};
static final GetStorage _storage = GetStorage('course_media_storage');
static Future<CourseMediaResponse> get(
CourseInfoBatchRequest request,
) async {
final urls = <CourseMediaInfo>[];
await _storage.initStorage;
urls.addAll(getCached(request).mediaUrls);
final toFetch = request.uuids
.where((uuid) => !urls.any((e) => e.uuid == uuid))
.toList();
if (toFetch.isNotEmpty) {
final fetchedUrls = await _fetch(
CourseInfoBatchRequest(
batchId: request.batchId,
uuids: toFetch,
),
);
urls.addAll(fetchedUrls.mediaUrls);
await _setCached(fetchedUrls);
}
return CourseMediaResponse(mediaUrls: urls);
}
static Future<CourseMediaResponse> _fetch(
CourseInfoBatchRequest request,
) async {
if (_cache.containsKey(request.batchId)) {
return _cache[request.batchId]!.future;
}
final completer = Completer<CourseMediaResponse>();
_cache[request.batchId] = completer;
final where = {
"id": {"in": request.uuids.join(",")},
};
final limit = request.uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanMediaResult = await payload.find(
CmsCoursePlanMedia.slug,
CmsCoursePlanMedia.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
if (cmsCoursePlanMediaResult.docs.isEmpty) {
return CourseMediaResponse(mediaUrls: []);
}
final response = CourseMediaResponse.fromCmsResponse(
cmsCoursePlanMediaResult,
);
completer.complete(response);
return response;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(request.batchId);
}
}
static CourseMediaResponse getCached(CourseInfoBatchRequest request) {
final urls = <CourseMediaInfo>[];
for (final uuid in request.uuids) {
final cached = _storage.read(uuid);
if (cached != null && cached is String) {
urls.add(
CourseMediaInfo(
uuid: uuid,
url: cached,
),
);
}
}
return CourseMediaResponse(mediaUrls: urls);
}
static Future<void> _setCached(CourseMediaResponse response) async {
final List<Future> futures = [];
for (final entry in response.mediaUrls) {
futures.add(_storage.write(entry.uuid, entry.url));
}
await Future.wait(futures);
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -0,0 +1,27 @@
import 'package:fluffychat/pangea/course_plans/course_media/course_media_info.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_media.dart';
import 'package:fluffychat/pangea/payload_client/paginated_response.dart';
class CourseMediaResponse {
final List<CourseMediaInfo> mediaUrls;
CourseMediaResponse({
required this.mediaUrls,
});
factory CourseMediaResponse.fromCmsResponse(
PayloadPaginatedResponse<CmsCoursePlanMedia> response,
) {
return CourseMediaResponse(
mediaUrls: response.docs
.where((e) => e.url != null)
.map(
(e) => CourseMediaInfo(
uuid: e.id,
url: e.url!,
),
)
.toList(),
);
}
}

View file

@ -1,109 +0,0 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_media.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseMediaRepo {
static final Map<String, Completer<Map<String, String>>> _cache = {};
static final GetStorage _storage = GetStorage('course_media_storage');
static List<String> getSync(List<String> uuids) {
final urls = <String>[];
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
urls.add(cached);
}
}
return urls;
}
static Future<List<String>> get(String courseId, List<String> uuids) async {
final urls = <String>[];
final toFetch = <String>[];
await _storage.initStorage;
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
urls.add(cached);
} else {
toFetch.add(uuid);
}
}
if (toFetch.isNotEmpty) {
final fetchedUrls = await _fetch(courseId, toFetch);
urls.addAll(fetchedUrls.values.toList());
for (final entry in fetchedUrls.entries) {
await _setCached(entry.key, entry.value);
}
}
return urls;
}
static String? _getCached(String uuid) => _storage.read(uuid);
static Future<void> _setCached(String uuid, String url) =>
_storage.write(uuid, url);
static Future<Map<String, String>> _fetch(
String courseId,
List<String> uuids,
) async {
if (_cache.containsKey(courseId)) {
return _cache[courseId]!.future;
}
final completer = Completer<Map<String, String>>();
_cache[courseId] = completer;
final where = {
"id": {"in": uuids.join(",")},
};
final limit = uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlanMediaResult = await payload.find(
CmsCoursePlanMedia.slug,
CmsCoursePlanMedia.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
if (cmsCoursePlanMediaResult.docs.isEmpty) {
return {};
}
final media = Map.fromEntries(
cmsCoursePlanMediaResult.docs
.where((media) => media.url != null)
.map((media) => MapEntry(media.id, media.url!)),
);
completer.complete(media);
return media;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(courseId);
}
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -1,90 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
class CoursePlanBuilder extends StatefulWidget {
final String? courseId;
final VoidCallback? onNotFound;
final Function(CoursePlanModel course)? onLoaded;
final Widget Function(
BuildContext context,
CoursePlanController controller,
) builder;
const CoursePlanBuilder({
super.key,
required this.courseId,
required this.builder,
this.onNotFound,
this.onLoaded,
});
@override
State<CoursePlanBuilder> createState() => CoursePlanController();
}
class CoursePlanController extends State<CoursePlanBuilder> {
bool loading = true;
Object? error;
CoursePlanModel? course;
@override
void initState() {
super.initState();
_initStorage().then((_) {
if (mounted) _loadCourse();
});
}
@override
void didUpdateWidget(covariant CoursePlanBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.courseId != widget.courseId) {
_loadCourse();
}
}
Future<void> _initStorage() async {
final futures = [
GetStorage.init("course_storage"),
GetStorage.init("course_activity_storage"),
GetStorage.init("course_location_media_storage"),
GetStorage.init("course_location_storage"),
GetStorage.init("course_media_storage"),
GetStorage.init("course_topic_storage"),
];
await Future.wait(futures);
}
Future<void> _loadCourse() async {
setState(() {
loading = true;
error = null;
course = null;
});
if (widget.courseId == null) {
widget.onNotFound?.call();
setState(() => loading = false);
return;
}
try {
course = await CoursePlansRepo.get(widget.courseId!);
widget.onLoaded?.call(course!);
} catch (e) {
widget.onNotFound?.call();
error = e;
} finally {
if (mounted) setState(() => loading = false);
}
}
@override
Widget build(BuildContext context) => widget.builder(context, this);
}

View file

@ -1,257 +0,0 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_location_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_location_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_repo.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseFilter {
final LanguageModel? targetLanguage;
final LanguageModel? languageOfInstructions;
final LanguageLevelTypeEnum? cefrLevel;
CourseFilter({
this.targetLanguage,
this.languageOfInstructions,
this.cefrLevel,
});
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CourseFilter &&
other.targetLanguage == targetLanguage &&
other.languageOfInstructions == languageOfInstructions &&
other.cefrLevel == cefrLevel;
}
@override
int get hashCode =>
targetLanguage.hashCode ^
languageOfInstructions.hashCode ^
cefrLevel.hashCode;
}
class CoursePlansRepo {
static final Map<String, Completer<CoursePlanModel>> cache = {};
static final GetStorage _courseStorage = GetStorage("course_storage");
static const Duration cacheDuration = Duration(days: 1);
static DateTime? get lastUpdated {
final entry = _courseStorage.read("last_updated");
if (entry != null && entry is String) {
try {
return DateTime.parse(entry);
} catch (e) {
_courseStorage.remove("last_updated");
}
}
return null;
}
static CoursePlanModel? _getCached(String id) {
if (lastUpdated != null &&
DateTime.now().difference(lastUpdated!) > cacheDuration) {
clearCache();
return null;
}
final json = _courseStorage.read(id);
if (json != null) {
try {
return CoursePlanModel.fromJson(json);
} catch (e) {
_courseStorage.remove(id);
}
}
return null;
}
static Future<void> _setCached(CoursePlanModel coursePlan) async {
if (lastUpdated == null) {
await _courseStorage.write(
"last_updated",
DateTime.now().toIso8601String(),
);
}
await _courseStorage.write(coursePlan.uuid, coursePlan.toJson());
}
static Future<CoursePlanModel> get(String id) async {
await _courseStorage.initStorage;
final cached = _getCached(id);
if (cached != null) {
return cached;
}
if (cache.containsKey(id)) {
return cache[id]!.future;
}
final completer = Completer<CoursePlanModel>();
cache[id] = completer;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCoursePlan = await payload.findById(
"course-plans",
id,
CmsCoursePlan.fromJson,
);
final coursePlan = cmsCoursePlan.toCoursePlanModel();
await _setCached(coursePlan);
await coursePlan.init();
completer.complete(coursePlan);
return coursePlan;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
cache.remove(id);
}
}
static Future<List<CoursePlanModel>> search(
List<String> ids, {
Map<String, dynamic>? where,
}) async {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final missingIds = ids
.where(
(id) => _courseStorage.read(id) == null,
)
.toList();
where ??= {};
where["id"] = {
"in": missingIds,
};
final searchResult = await payload.find(
CmsCoursePlan.slug,
CmsCoursePlan.fromJson,
page: 1,
limit: 10,
where: where,
);
final coursePlans = searchResult.docs
.map(
(cmsCoursePlan) => cmsCoursePlan.toCoursePlanModel(),
)
.toList();
for (final plan in coursePlans) {
await _setCached(plan);
}
final futures = coursePlans.map((c) => c.init());
await Future.wait(futures);
return ids
.map((id) => _getCached(id))
.whereType<CoursePlanModel>()
.toList();
}
static Future<List<CoursePlanModel>> searchByFilter({
CourseFilter? filter,
}) async {
await _courseStorage.initStorage;
final Map<String, dynamic> where = {};
if (filter != null) {
int numberOfFilter = 0;
if (filter.cefrLevel != null) {
numberOfFilter += 1;
}
if (filter.languageOfInstructions != null) {
numberOfFilter += 1;
}
if (filter.targetLanguage != null) {
numberOfFilter += 1;
}
if (numberOfFilter > 1) {
where["and"] = [];
if (filter.cefrLevel != null) {
where["and"].add({
"cefrLevel": {"equals": filter.cefrLevel!.string},
});
}
if (filter.languageOfInstructions != null) {
where["and"].add({
"l1": {
"equals": filter.languageOfInstructions!.langCodeShort,
},
});
}
if (filter.targetLanguage != null) {
where["and"].add({
"l2": {"equals": filter.targetLanguage!.langCodeShort},
});
}
} else if (numberOfFilter == 1) {
if (filter.cefrLevel != null) {
where["cefrLevel"] = {"equals": filter.cefrLevel!.string};
}
if (filter.languageOfInstructions != null) {
where["l1"] = {
"equals": filter.languageOfInstructions!.langCode,
};
}
if (filter.targetLanguage != null) {
where["l2"] = {"equals": filter.targetLanguage!.langCode};
}
}
}
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
// Run the search for the given filter, selecting only the course IDs
final result = await payload.find(
CmsCoursePlan.slug,
(json) => json["id"] as String,
page: 1,
limit: 10,
where: where,
select: {"id": true},
);
return search(result.docs, where: where);
}
static Future<void> clearCache() async {
final List<Future> futures = [
CourseActivityRepo.clearCache(),
CourseLocationMediaRepo.clearCache(),
CourseLocationRepo.clearCache(),
CourseMediaRepo.clearCache(),
CourseTopicRepo.clearCache(),
_courseStorage.erase(),
];
await Future.wait(futures);
}
}

View file

@ -1,93 +0,0 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_location_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_location_model.dart';
import 'package:fluffychat/pangea/course_plans/course_location_repo.dart';
/// Represents a topic in the course planner response.
class CourseTopicModel {
final String title;
final String description;
final String uuid;
final List<String> locationIds;
final List<String> activityIds;
CourseTopicModel({
required this.title,
required this.description,
required this.uuid,
required this.activityIds,
required this.locationIds,
});
bool get locationListComplete => locationIds.length == loadedLocations.length;
List<CourseLocationModel> get loadedLocations =>
CourseLocationRepo.getSync(locationIds);
Future<List<CourseLocationModel>> fetchLocations() =>
CourseLocationRepo.get(uuid, locationIds);
String? get location => loadedLocations.firstOrNull?.name;
bool get locationMediaListComplete =>
loadedLocationMediaIds.length ==
loadedLocations.map((e) => e.mediaIds.length).fold(0, (a, b) => a + b);
List<String> get loadedLocationMediaIds => loadedLocations
.map((location) => CourseLocationMediaRepo.getSync(location.mediaIds))
.expand((e) => e)
.toList();
Future<List<String>> fetchLocationMedia() async {
final allLocationMedia = <String>[];
for (final location in await fetchLocations()) {
allLocationMedia.addAll(
await CourseLocationMediaRepo.get(uuid, location.mediaIds),
);
}
return allLocationMedia;
}
String? get imageUrl => loadedLocationMediaIds.isEmpty
? null
: "${Environment.cmsApi}${loadedLocationMediaIds.first}";
bool get activityListComplete =>
activityIds.length == loadedActivities.length;
List<ActivityPlanModel> get loadedActivities =>
CourseActivityRepo.getSync(activityIds);
Future<List<ActivityPlanModel>> fetchActivities() =>
CourseActivityRepo.get(uuid, activityIds);
ActivityPlanModel? activityById(String activityId) =>
loadedActivities.firstWhereOrNull(
(activity) => activity.activityId == activityId,
);
/// Deserialize from JSON
factory CourseTopicModel.fromJson(Map<String, dynamic> json) {
return CourseTopicModel(
title: json['title'] as String,
description: json['description'] as String,
uuid: json['uuid'] as String,
activityIds: (json['activity_ids'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
locationIds: (json['location_ids'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
);
}
/// Serialize to JSON
Map<String, dynamic> toJson() {
return {
'title': title,
'description': description,
'uuid': uuid,
'activity_ids': activityIds,
'location_ids': locationIds,
};
}
}

View file

@ -1,120 +0,0 @@
import 'dart:async';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/pangea/payload_client/models/course_plan/cms_course_plan_topic.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseTopicRepo {
static final Map<String, Completer<List<CourseTopicModel>>> _cache = {};
static final GetStorage _storage = GetStorage('course_topic_storage');
static List<CourseTopicModel> getSync(List<String> uuids) {
final topics = <CourseTopicModel>[];
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
topics.add(cached);
}
}
return topics;
}
static Future<List<CourseTopicModel>> get(
String courseId,
List<String> uuids,
) async {
final topics = <CourseTopicModel>[];
final toFetch = <String>[];
await _storage.initStorage;
for (final uuid in uuids) {
final cached = _getCached(uuid);
if (cached != null) {
topics.add(cached);
} else {
toFetch.add(uuid);
}
}
if (toFetch.isNotEmpty) {
final fetchedTopics = await _fetch(courseId, toFetch);
topics.addAll(fetchedTopics);
await _setCached(fetchedTopics);
}
return topics;
}
static CourseTopicModel? _getCached(String uuid) {
final json = _storage.read(uuid);
if (json != null) {
try {
return CourseTopicModel.fromJson(Map<String, dynamic>.from(json));
} catch (e) {
_storage.remove(uuid);
}
}
return null;
}
static Future<void> _setCached(List<CourseTopicModel> topics) async {
for (final topic in topics) {
await _storage.write(topic.uuid, topic.toJson());
}
}
static Future<List<CourseTopicModel>> _fetch(
String courseId,
List<String> uuids,
) async {
if (_cache.containsKey(courseId)) {
return _cache[courseId]!.future;
}
final completer = Completer<List<CourseTopicModel>>();
_cache[courseId] = completer;
final where = {
"id": {"in": uuids.join(",")},
};
final limit = uuids.length;
try {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final cmsCourseTopicsResult = await payload.find(
CmsCoursePlanTopic.slug,
CmsCoursePlanTopic.fromJson,
where: where,
limit: limit,
page: 1,
sort: "createdAt",
);
final topics = cmsCourseTopicsResult.docs.map((topic) {
return topic.toCourseTopicModel();
}).toList();
completer.complete(topics);
return topics;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(courseId);
}
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -0,0 +1,137 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_translation_request.dart';
import 'package:fluffychat/pangea/course_plans/course_info_batch_request.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_response.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Represents a topic in the course planner response.
class CourseTopicModel {
final String title;
final String description;
final String uuid;
final List<String> locationIds;
final List<String> activityIds;
CourseTopicModel({
required this.title,
required this.description,
required this.uuid,
required this.activityIds,
required this.locationIds,
});
bool get locationListComplete =>
locationIds.length == loadedLocations.locations.length;
CourseLocationResponse get loadedLocations => CourseLocationRepo.getCached(
CourseInfoBatchRequest(
batchId: uuid,
uuids: locationIds,
),
);
Future<CourseLocationResponse> fetchLocations() => CourseLocationRepo.get(
CourseInfoBatchRequest(
batchId: uuid,
uuids: locationIds,
),
);
String? get location => loadedLocations.locations.firstOrNull?.name;
bool get locationMediaListComplete =>
loadedLocationMediaIds.length ==
loadedLocations.locations
.map((e) => e.mediaIds.length)
.fold(0, (a, b) => a + b);
List<String> get loadedLocationMediaIds => loadedLocations.locations
.map(
(location) => CourseLocationMediaRepo.getCached(
CourseInfoBatchRequest(
batchId: uuid,
uuids: location.mediaIds,
),
).mediaUrls,
)
.expand((e) => e)
.map((e) => e.url)
.toList();
Future<List<String>> fetchLocationMedia() async {
final allLocationMedia = <String>[];
final locationResp = await fetchLocations();
for (final location in locationResp.locations) {
final mediaResp = await CourseLocationMediaRepo.get(
CourseInfoBatchRequest(
batchId: uuid,
uuids: location.mediaIds,
),
);
allLocationMedia.addAll(mediaResp.mediaUrls.map((e) => e.url));
}
return allLocationMedia;
}
String? get imageUrl => loadedLocationMediaIds.isEmpty
? null
: "${Environment.cmsApi}${loadedLocationMediaIds.first}";
bool get activityListComplete =>
activityIds.length == loadedActivities.length;
Map<String, ActivityPlanModel> get loadedActivities =>
CourseActivityRepo.getCached(
TranslateActivityRequest(
activityIds: activityIds,
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
).plans;
Future<Map<String, ActivityPlanModel>> fetchActivities() async {
final resp = await CourseActivityRepo.get(
TranslateActivityRequest(
activityIds: activityIds,
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
uuid,
);
return resp.plans;
}
/// Deserialize from JSON
factory CourseTopicModel.fromJson(Map<String, dynamic> json) {
final List<dynamic>? activityIdsEntry =
json['activity_ids'] as List<dynamic>? ??
json['activityIds'] as List<dynamic>?;
final List<dynamic>? locationIdsEntry =
json['location_ids'] as List<dynamic>? ??
json['locationIds'] as List<dynamic>?;
return CourseTopicModel(
title: json['title'] as String,
description: json['description'] as String,
uuid: json['uuid'] as String,
activityIds: activityIdsEntry?.map((e) => e as String).toList() ?? [],
locationIds: locationIdsEntry?.map((e) => e as String).toList() ?? [],
);
}
/// Serialize to JSON
Map<String, dynamic> toJson() {
return {
'title': title,
'description': description,
'uuid': uuid,
'activity_ids': activityIds,
'location_ids': locationIds,
};
}
}

View file

@ -0,0 +1,135 @@
import 'dart:async';
import 'dart:convert';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_translation_request.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_translation_response.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseTopicRepo {
static final Map<String, Completer<TranslateTopicResponse>> _cache = {};
static final GetStorage _storage = GetStorage('course_topic_storage');
static Future<TranslateTopicResponse> get(
TranslateTopicRequest request,
String batchId,
) async {
await _storage.initStorage;
final topics = getCached(request).topics;
final toFetch =
request.topicIds.where((uuid) => !topics.containsKey(uuid)).toList();
if (toFetch.isNotEmpty) {
final fetchedTopics = await _fetch(
TranslateTopicRequest(
topicIds: toFetch,
l1: request.l1,
),
batchId,
);
topics.addAll(fetchedTopics.topics);
await _setCached(
fetchedTopics,
request.l1,
);
}
return TranslateTopicResponse(topics: topics);
}
static Future<TranslateTopicResponse> translate(
TranslateTopicRequest request,
) async {
final Requests req = Requests(
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res = await req.post(
url: PApiUrls.getLocalizedTopic,
body: request.toJson(),
);
if (res.statusCode != 200) {
throw Exception(
"Failed to translate topic. Status code: ${res.statusCode}",
);
}
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = TranslateTopicResponse.fromJson(decodedBody);
return response;
}
static Future<TranslateTopicResponse> _fetch(
TranslateTopicRequest request,
String batchId,
) async {
if (_cache.containsKey(batchId)) {
return _cache[batchId]!.future;
}
final completer = Completer<TranslateTopicResponse>();
_cache[batchId] = completer;
try {
final response = await translate(request);
completer.complete(response);
return response;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
_cache.remove(batchId);
}
}
static TranslateTopicResponse getCached(
TranslateTopicRequest request,
) {
final Map<String, CourseTopicModel> topics = {};
for (final uuid in request.topicIds) {
final cacheKey = "${uuid}_${request.l1}";
final json = _storage.read(cacheKey);
if (json != null) {
try {
final topic = CourseTopicModel.fromJson(
Map<String, dynamic>.from(json),
);
topics[uuid] = topic;
} catch (e) {
_storage.remove(cacheKey);
}
}
}
return TranslateTopicResponse(topics: topics);
}
static Future<void> _setCached(
TranslateTopicResponse response,
String l1,
) async {
final List<Future> futures = [];
for (final entry in response.topics.entries) {
futures.add(
_storage.write(
"${entry.key}_$l1",
entry.value.toJson(),
),
);
}
await Future.wait(futures);
}
static Future<void> clearCache() async {
await _storage.erase();
}
}

View file

@ -0,0 +1,22 @@
class TranslateTopicRequest {
List<String> topicIds;
String l1;
TranslateTopicRequest({
required this.topicIds,
required this.l1,
});
Map<String, dynamic> toJson() => {
"topic_ids": topicIds,
"l1": l1,
};
factory TranslateTopicRequest.fromJson(Map<String, dynamic> json) {
return TranslateTopicRequest(
topicIds:
json['topic_ids'] != null ? List<String>.from(json['topic_ids']) : [],
l1: json['l1'],
);
}
}

View file

@ -0,0 +1,23 @@
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_model.dart';
class TranslateTopicResponse {
final Map<String, CourseTopicModel> topics;
TranslateTopicResponse({required this.topics});
factory TranslateTopicResponse.fromJson(Map<String, dynamic> json) {
final topicsEntry = json['topics'] as Map<String, dynamic>;
return TranslateTopicResponse(
topics: topicsEntry.map(
(key, value) => MapEntry(
key,
CourseTopicModel.fromJson(value),
),
),
);
}
Map<String, dynamic> toJson() => {
"topics": topics.map((key, value) => MapEntry(key, value.toJson())),
};
}

View file

@ -0,0 +1,78 @@
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
class CourseFilter {
final LanguageModel? targetLanguage;
final LanguageModel? languageOfInstructions;
final LanguageLevelTypeEnum? cefrLevel;
CourseFilter({
this.targetLanguage,
this.languageOfInstructions,
this.cefrLevel,
});
Map<String, dynamic> get whereFilter {
final Map<String, dynamic> where = {};
int numberOfFilter = 0;
if (cefrLevel != null) {
numberOfFilter += 1;
}
if (languageOfInstructions != null) {
numberOfFilter += 1;
}
if (targetLanguage != null) {
numberOfFilter += 1;
}
if (numberOfFilter > 1) {
where["and"] = [];
if (cefrLevel != null) {
where["and"].add({
"cefrLevel": {"equals": cefrLevel!.string},
});
}
if (languageOfInstructions != null) {
where["and"].add({
"l1": {
"equals": languageOfInstructions!.langCodeShort,
},
});
}
if (targetLanguage != null) {
where["and"].add({
"l2": {"equals": targetLanguage!.langCodeShort},
});
}
} else if (numberOfFilter == 1) {
if (cefrLevel != null) {
where["cefrLevel"] = {"equals": cefrLevel!.string};
}
if (languageOfInstructions != null) {
where["l1"] = {
"equals": languageOfInstructions!.langCode,
};
}
if (targetLanguage != null) {
where["l2"] = {"equals": targetLanguage!.langCode};
}
}
return where;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CourseFilter &&
other.targetLanguage == targetLanguage &&
other.languageOfInstructions == languageOfInstructions &&
other.cefrLevel == cefrLevel;
}
@override
int get hashCode =>
targetLanguage.hashCode ^
languageOfInstructions.hashCode ^
cefrLevel.hashCode;
}

View file

@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'course_plans_repo.dart';
mixin CoursePlanProvider<T extends StatefulWidget> on State<T> {
bool loadingCourse = true;
Object? courseError;
bool loadingTopics = false;
Object? topicError;
Map<String, Object?> activityErrors = {};
CoursePlanModel? course;
Future<void> _initStorage() async {
final futures = [
GetStorage.init("course_storage"),
GetStorage.init("course_activity_storage"),
GetStorage.init("course_location_media_storage"),
GetStorage.init("course_location_storage"),
GetStorage.init("course_media_storage"),
GetStorage.init("course_topic_storage"),
];
await Future.wait(futures);
}
Future<void> loadCourse(String courseId) async {
await _initStorage();
setState(() {
loadingCourse = true;
courseError = null;
course = null;
});
try {
course = await CoursePlansRepo.get(
GetLocalizedCoursesRequest(
coursePlanIds: [courseId],
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
);
} catch (e) {
courseError = e;
} finally {
if (mounted) setState(() => loadingCourse = false);
}
}
Future<void> loadTopics() async {
setState(() {
loadingTopics = true;
topicError = null;
});
try {
if (course == null) {
throw Exception("Course is null");
}
final courseFutures = <Future>[
course!.fetchMediaUrls(),
course!.fetchTopics(),
];
await Future.wait(courseFutures);
} catch (e) {
topicError = e;
} finally {
if (mounted) setState(() => loadingTopics = false);
}
}
Future<void> loadActivity(String topicId) async {
setState(() {
activityErrors[topicId] = null;
});
try {
final topic = course?.loadedTopics[topicId];
if (topic == null) {
throw Exception("Topic is null");
}
final topicFutures = <Future>[];
topicFutures.add(topic.fetchActivities());
topicFutures.add(topic.fetchLocationMedia());
await Future.wait(topicFutures);
} catch (e) {
activityErrors[topicId] = e;
} finally {
if (mounted) {
setState(() {});
}
}
}
}

View file

@ -1,13 +1,16 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_info_batch_request.dart';
import 'package:fluffychat/pangea/course_plans/course_media/course_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_media/course_media_response.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_translation_request.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Represents a course plan in the course planner response.
class CoursePlanModel {
@ -53,28 +56,6 @@ class CoursePlanModel {
baseLanguageModel?.langCode.toUpperCase() ??
languageOfInstructions.toUpperCase();
String? topicID(String activityID) {
for (final topic in loadedTopics) {
if (topic.activityIds.any((id) => id == activityID)) {
return topic.uuid;
}
}
return null;
}
int get totalActivities =>
loadedTopics.fold(0, (sum, topic) => sum + topic.activityIds.length);
ActivityPlanModel? activityById(String activityID) {
for (final topic in loadedTopics) {
final activity = topic.activityById(activityID);
if (activity != null) {
return activity;
}
}
return null;
}
/// Deserialize from JSON
factory CoursePlanModel.fromJson(Map<String, dynamic> json) {
return CoursePlanModel(
@ -114,37 +95,42 @@ class CoursePlanModel {
}
bool get topicListComplete => topicIds.length == loadedTopics.length;
List<CourseTopicModel> get loadedTopics => CourseTopicRepo.getSync(topicIds);
Future<List<CourseTopicModel>> fetchTopics() =>
CourseTopicRepo.get(uuid, topicIds);
bool get mediaListComplete => mediaIds.length == loadedMediaUrls.length;
List<String> get loadedMediaUrls => CourseMediaRepo.getSync(mediaIds);
Future<List<String>> fetchMediaUrls() => CourseMediaRepo.get(uuid, mediaIds);
String? get imageUrl => loadedMediaUrls.isEmpty
? loadedTopics
Map<String, CourseTopicModel> get loadedTopics => CourseTopicRepo.getCached(
TranslateTopicRequest(
topicIds: topicIds,
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
).topics;
Future<Map<String, CourseTopicModel>> fetchTopics() async {
final resp = await CourseTopicRepo.get(
TranslateTopicRequest(
topicIds: topicIds,
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
uuid,
);
return resp.topics;
}
bool get mediaListComplete =>
mediaIds.length == loadedMediaUrls.mediaUrls.length;
CourseMediaResponse get loadedMediaUrls => CourseMediaRepo.getCached(
CourseInfoBatchRequest(
batchId: uuid,
uuids: mediaIds,
),
);
Future<CourseMediaResponse> fetchMediaUrls() => CourseMediaRepo.get(
CourseInfoBatchRequest(
batchId: uuid,
uuids: mediaIds,
),
);
String? get imageUrl => loadedMediaUrls.mediaUrls.isEmpty
? loadedTopics.values
.lastWhereOrNull((topic) => topic.imageUrl != null)
?.imageUrl
: "${Environment.cmsApi}${loadedMediaUrls.first}";
Future<void> init() async {
final courseFutures = <Future>[
fetchMediaUrls(),
fetchTopics(),
];
await Future.wait(courseFutures);
final topicFutures = <Future>[];
topicFutures.addAll(
loadedTopics.map(
(topic) => topic.fetchActivities(),
),
);
topicFutures.addAll(
loadedTopics.map(
(topic) => topic.fetchLocationMedia(),
),
);
await Future.wait(topicFutures);
}
: "${Environment.cmsApi}${loadedMediaUrls.mediaUrls.first}";
}

View file

@ -7,7 +7,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_roles_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_event.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_event.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';

View file

@ -0,0 +1,266 @@
import 'dart:async';
import 'dart:convert';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.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/pangea/course_plans/course_activities/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_media/course_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_repo.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_filter.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart';
import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_response.dart';
import 'package:fluffychat/pangea/payload_client/payload_client.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CoursePlansRepo {
static final Map<String, Completer<CoursePlanModel>> cache = {};
static final GetStorage _courseStorage = GetStorage("course_storage");
static const Duration cacheDuration = Duration(days: 1);
static DateTime? get lastUpdated {
final entry = _courseStorage.read("last_updated");
if (entry != null && entry is String) {
try {
return DateTime.parse(entry);
} catch (e) {
_courseStorage.remove("last_updated");
}
}
return null;
}
// TODO: Currently just getting one course plan at a time
// Should take advantage of batch fetching
static Future<CoursePlanModel> get(
GetLocalizedCoursesRequest request,
) async {
if (request.coursePlanIds.length != 1) {
throw Exception("Get only supports single course plan ID");
}
await _courseStorage.initStorage;
final cached = _getCached(request);
if (cached != null) {
return cached;
}
final uuid = request.coursePlanIds.first;
if (cache.containsKey(uuid)) {
return cache[uuid]!.future;
}
final completer = Completer<CoursePlanModel>();
cache[uuid] = completer;
try {
final translation = await _fetch(request);
final coursePlan = translation.coursePlans[uuid];
if (coursePlan == null) {
throw Exception("Course plan not found after translation");
}
await _setCached(coursePlan, uuid, request.l1);
completer.complete(coursePlan);
return coursePlan;
} catch (e) {
completer.completeError(e);
rethrow;
} finally {
cache.remove(uuid);
}
}
static Future<GetLocalizedCoursesResponse> _fetch(
GetLocalizedCoursesRequest request,
) async {
final Requests req = Requests(
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res = await req.post(
url: PApiUrls.getLocalizedCourse,
body: request.toJson(),
);
if (res.statusCode != 200) {
throw Exception(
"Failed to translate course plan: ${res.statusCode} ${res.body}",
);
}
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = GetLocalizedCoursesResponse.fromJson(decodedBody);
return response;
}
static Future<GetLocalizedCoursesResponse> search(
GetLocalizedCoursesRequest request,
) async {
await _courseStorage.initStorage;
final missingIds = request.coursePlanIds
.where(
(id) => _courseStorage.read("${id}_${request.l1}") == null,
)
.toList();
if (missingIds.isNotEmpty) {
final searchResult = await _fetch(
GetLocalizedCoursesRequest(
coursePlanIds: missingIds,
l1: request.l1,
),
);
await _setCachedBatch(
searchResult,
request.l1,
);
}
return _getCachedBatch(request);
}
static Future<GetLocalizedCoursesResponse> searchByFilter({
required CourseFilter filter,
}) async {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
// Run the search for the given filter, selecting only the course IDs
final result = await payload.find(
"course-plans",
(json) => json["id"] as String,
page: 1,
limit: 10,
where: filter.whereFilter,
select: {"id": true},
);
return search(
GetLocalizedCoursesRequest(
coursePlanIds: result.docs,
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
);
}
static CoursePlanModel? _getCached(
GetLocalizedCoursesRequest request,
) {
if (lastUpdated != null &&
DateTime.now().difference(lastUpdated!) > cacheDuration) {
clearCache();
return null;
}
if (request.coursePlanIds.length != 1) {
throw Exception("Get cached only supports single course plan ID");
}
final uuid = request.coursePlanIds.first;
final cacheKey = "${uuid}_${request.l1}";
final json = _courseStorage.read(cacheKey);
if (json != null) {
try {
return CoursePlanModel.fromJson(json);
} catch (e) {
_courseStorage.remove(cacheKey);
}
}
return null;
}
static GetLocalizedCoursesResponse _getCachedBatch(
GetLocalizedCoursesRequest request,
) {
if (lastUpdated != null &&
DateTime.now().difference(lastUpdated!) > cacheDuration) {
clearCache();
return GetLocalizedCoursesResponse(coursePlans: {});
}
final Map<String, CoursePlanModel> courses = {};
for (final uuid in request.coursePlanIds) {
final cacheKey = "${uuid}_${request.l1}";
final json = _courseStorage.read(cacheKey);
if (json != null) {
try {
final course = CoursePlanModel.fromJson(
Map<String, dynamic>.from(json),
);
courses[uuid] = course;
} catch (e) {
_courseStorage.remove(cacheKey);
}
}
}
return GetLocalizedCoursesResponse(coursePlans: courses);
}
static Future<void> _setCached(
CoursePlanModel course,
String courseId,
String l1,
) async {
if (lastUpdated == null) {
await _courseStorage.write(
"last_updated",
DateTime.now().toIso8601String(),
);
}
final cacheKey = "${courseId}_$l1";
await _courseStorage.write(
cacheKey,
course.toJson(),
);
}
static Future<void> _setCachedBatch(
GetLocalizedCoursesResponse response,
String l1,
) async {
if (lastUpdated == null) {
await _courseStorage.write(
"last_updated",
DateTime.now().toIso8601String(),
);
}
final List<Future> futures = response.coursePlans.entries.map((entry) {
final cacheKey = "${entry.key}_$l1";
return _courseStorage.write(
cacheKey,
entry.value.toJson(),
);
}).toList();
await Future.wait(futures);
}
static Future<void> clearCache() async {
final List<Future> futures = [
CourseActivityRepo.clearCache(),
CourseLocationMediaRepo.clearCache(),
CourseLocationRepo.clearCache(),
CourseMediaRepo.clearCache(),
CourseTopicRepo.clearCache(),
_courseStorage.erase(),
];
await Future.wait(futures);
}
}

View file

@ -0,0 +1,23 @@
class GetLocalizedCoursesRequest {
final List<String> coursePlanIds;
final String l1;
GetLocalizedCoursesRequest({
required this.coursePlanIds,
required this.l1,
});
Map<String, dynamic> toJson() => {
"course_plan_ids": coursePlanIds,
"l1": l1,
};
factory GetLocalizedCoursesRequest.fromJson(Map<String, dynamic> json) {
return GetLocalizedCoursesRequest(
coursePlanIds: json['course_plan_ids'] != null
? List<String>.from(json['course_plan_ids'])
: [],
l1: json['l1'],
);
}
}

View file

@ -0,0 +1,28 @@
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
class GetLocalizedCoursesResponse {
final Map<String, CoursePlanModel> coursePlans;
GetLocalizedCoursesResponse({required this.coursePlans});
factory GetLocalizedCoursesResponse.fromJson(Map<String, dynamic> json) {
final plansEntry = json['course_plans'] as Map<String, dynamic>;
return GetLocalizedCoursesResponse(
coursePlans: plansEntry.map(
(key, value) => MapEntry(
key,
CoursePlanModel.fromJson(value),
),
),
);
}
Map<String, dynamic> toJson() => {
"course_plans": coursePlans.map(
(key, value) => MapEntry(
key,
value.toJson(),
),
),
};
}

View file

@ -12,20 +12,19 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.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/url_image_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_plans/activity_summaries_provider.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_settings/pin_clipper.dart';
import 'package:fluffychat/pangea/course_settings/topic_participant_list.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
class CourseSettings extends StatefulWidget {
final Room room;
final CoursePlanController controller;
const CourseSettings(
this.controller, {
const CourseSettings({
super.key,
required this.room,
});
@ -35,14 +34,43 @@ class CourseSettings extends StatefulWidget {
}
class CourseSettingsState extends State<CourseSettings>
with ActivitySummariesProvider {
CoursePlanController get controller => widget.controller;
with ActivitySummariesProvider, CoursePlanProvider {
Room get room => widget.room;
bool _loadingActivities = true;
@override
void initState() {
super.initState();
_loadSummaries();
if (widget.room.coursePlan != null) {
_loadCourseInfo();
}
}
@override
void didUpdateWidget(covariant CourseSettings oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.room.id != widget.room.id) {
if (widget.room.coursePlan != null) {
_loadCourseInfo();
}
}
}
Future<void> _loadCourseInfo() async {
setState(() => _loadingActivities = true);
await loadCourse(widget.room.coursePlan!.uuid);
if (course != null) {
await loadTopics();
final futures = <Future>[];
for (final topicId in course!.topicIds) {
futures.add(loadActivity(topicId));
}
await Future.wait(futures);
}
if (mounted) setState(() => _loadingActivities = false);
}
Future<void> _loadSummaries() async {
@ -64,11 +92,11 @@ class CourseSettingsState extends State<CourseSettings>
@override
Widget build(BuildContext context) {
if (controller.loading) {
if (loadingCourse) {
return const Center(child: CircularProgressIndicator.adaptive());
}
if (controller.course == null || controller.error != null) {
if (course == null || courseError != null) {
return room.canChangeStateEvent(PangeaEventTypes.coursePlan)
? Column(
spacing: 50.0,
@ -114,149 +142,172 @@ class CourseSettingsState extends State<CourseSettings>
final double descFontSize = isColumnMode ? 12.0 : 8.0;
final double iconSize = isColumnMode ? 16.0 : 12.0;
final course = controller.course!;
final topicIndex = currentTopicIndex(
if (loadingTopics) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final activeTopicId = currentTopicId(
room.client.userID!,
course,
course!,
);
return FutureBuilder(
future: topicsToUsers(room, course),
builder: (context, snapshot) {
final topicsToUsers = snapshot.data ?? {};
return Column(
spacing: isColumnMode ? 40.0 : 36.0,
mainAxisSize: MainAxisSize.min,
children: course.loadedTopics.mapIndexed((index, topic) {
final unlocked = index <= topicIndex;
final usersInTopic = topicsToUsers[topic.uuid] ?? [];
final activities = topic.loadedActivities;
activities.sort(
(a, b) => a.req.numberOfParticipants.compareTo(
b.req.numberOfParticipants,
),
);
return AbsorbPointer(
absorbing: !unlocked,
child: Opacity(
opacity: unlocked ? 1.0 : 0.5,
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
LayoutBuilder(
builder: (context, constraints) {
return Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
final int? topicIndex =
activeTopicId == null ? null : course!.topicIds.indexOf(activeTopicId);
final Map<String, List<User>> userTopics = _loadingActivities
? {}
: topicsToUsers(
room,
course!,
);
return Column(
spacing: isColumnMode ? 40.0 : 36.0,
mainAxisSize: MainAxisSize.min,
children: course!.topicIds.mapIndexed((index, topicId) {
final topic = course!.loadedTopics[topicId];
if (topic == null) {
return const SizedBox();
}
final usersInTopic = userTopics[topicId] ?? [];
final activityError = activityErrors[topicId];
final bool locked = topicIndex == null ? false : index > topicIndex;
final disabled = locked || _loadingActivities || activityError != null;
return AbsorbPointer(
absorbing: disabled,
child: Opacity(
opacity: disabled ? 0.5 : 1.0,
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
LayoutBuilder(
builder: (context, constraints) {
return Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
Stack(
children: [
Stack(
children: [
ClipPath(
clipper: PinClipper(),
child: ImageByUrl(
imageUrl: topic.imageUrl,
width: 54.0,
replacement: Container(
width: 54.0,
height: 54.0,
decoration: BoxDecoration(
color:
theme.colorScheme.secondary,
),
),
ClipPath(
clipper: PinClipper(),
child: ImageByUrl(
imageUrl: topic.imageUrl,
width: 54.0,
replacement: Container(
width: 54.0,
height: 54.0,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
),
),
if (!unlocked)
const Positioned(
bottom: 0,
right: 0,
child: Icon(Icons.lock, size: 24.0),
),
],
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
topic.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: titleFontSize,
),
),
if (topic.location != null)
CourseInfoChip(
icon: Icons.location_on,
text: topic.location!,
fontSize: descFontSize,
iconSize: iconSize,
),
if (constraints.maxWidth < 700.0)
Padding(
padding: const EdgeInsetsGeometry
.symmetric(
vertical: 4.0,
),
child: TopicParticipantList(
room: room,
users: usersInTopic,
avatarSize:
isColumnMode ? 50.0 : 25.0,
overlap:
isColumnMode ? 20.0 : 8.0,
),
),
],
),
),
if (locked)
const Positioned(
bottom: 0,
right: 0,
child: Icon(Icons.lock, size: 24.0),
)
else if (_loadingActivities)
const SizedBox(
width: 54.0,
height: 54.0,
child:
CircularProgressIndicator.adaptive(),
),
],
),
),
if (constraints.maxWidth >= 700.0)
TopicParticipantList(
room: room,
users: usersInTopic,
avatarSize: isColumnMode ? 50.0 : 25.0,
overlap: isColumnMode ? 20.0 : 8.0,
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
topic.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: titleFontSize,
),
),
if (topic.location != null)
CourseInfoChip(
icon: Icons.location_on,
text: topic.location!,
fontSize: descFontSize,
iconSize: iconSize,
),
if (constraints.maxWidth < 700.0)
Padding(
padding:
const EdgeInsetsGeometry.symmetric(
vertical: 4.0,
),
child: TopicParticipantList(
room: room,
users: usersInTopic,
avatarSize:
isColumnMode ? 50.0 : 25.0,
overlap: isColumnMode ? 20.0 : 8.0,
),
),
],
),
),
],
);
},
),
if (unlocked)
SizedBox(
height: isColumnMode ? 290.0 : 210.0,
child: TopicActivitiesList(
room: room,
activities: activities,
controller: this,
],
),
),
),
],
if (constraints.maxWidth >= 700.0)
TopicParticipantList(
room: room,
users: usersInTopic,
avatarSize: isColumnMode ? 50.0 : 25.0,
overlap: isColumnMode ? 20.0 : 8.0,
),
],
);
},
),
),
);
}).toList(),
if (!locked)
_loadingActivities
? const Center(
child: CircularProgressIndicator.adaptive(),
)
: activityError != null
? ErrorIndicator(
message: L10n.of(context).oopsSomethingWentWrong,
)
: topic.loadedActivities.isNotEmpty
? SizedBox(
height: isColumnMode ? 290.0 : 210.0,
child: TopicActivitiesList(
room: room,
activities: topic.loadedActivities,
controller: this,
),
)
: const SizedBox(),
],
),
),
);
},
}).toList(),
);
}
}
class TopicActivitiesList extends StatefulWidget {
final Room room;
final List<ActivityPlanModel> activities;
final Map<String, ActivityPlanModel> activities;
final CourseSettingsState controller;
const TopicActivitiesList({
@ -282,26 +333,33 @@ class TopicActivitiesListState extends State<TopicActivitiesList> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final activityEntries = widget.activities.entries.toList();
activityEntries.sort(
(a, b) => a.value.req.numberOfParticipants.compareTo(
b.value.req.numberOfParticipants,
),
);
return Scrollbar(
thumbVisibility: true,
controller: _scrollController,
child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: widget.activities.length,
itemCount: activityEntries.length,
itemBuilder: (context, index) {
final activityId = widget.activities[index].activityId;
final activityEntry = activityEntries[index];
final complete = widget.controller.hasCompletedActivity(
widget.room.client.userID!,
activityId,
activityEntry.key,
);
final activityRoomId = widget.room.activeActivityRoomId(
activityId,
activityEntry.key,
);
final activity = widget.activities[index];
final activity = activityEntry.value;
return Padding(
padding: const EdgeInsets.only(right: 24.0),
child: MouseRegion(
@ -310,7 +368,7 @@ class TopicActivitiesListState extends State<TopicActivitiesList> {
onTap: () => context.go(
activityRoomId != null
? "/rooms/spaces/${widget.room.id}/$activityRoomId"
: "/rooms/spaces/${widget.room.id}/activity/$activityId",
: "/rooms/spaces/${widget.room.id}/activity/${activityEntry.key}",
),
child: Stack(
children: [

View file

@ -10,8 +10,9 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart';
import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
import 'package:fluffychat/pangea/login/utils/lang_code_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -99,7 +100,12 @@ class CreatePangeaAccountPageState extends State<CreatePangeaAccountPage> {
throw Exception('No course plan associated with space $spaceId');
}
final course = await CoursePlansRepo.get(courseId);
final course = await CoursePlansRepo.get(
GetLocalizedCoursesRequest(
coursePlanIds: [courseId],
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
);
_spaceId = spaceId;
_courseLangCode = course.targetLanguage;

View file

@ -43,6 +43,7 @@ class NewTripPageState extends State<NewTripPage> with CourseSearchProvider {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final spaceId = widget.spaceId;
final courseEntries = courses.entries.toList();
return Scaffold(
appBar: AppBar(
title: Row(
@ -142,14 +143,15 @@ class NewTripPageState extends State<NewTripPage> with CourseSearchProvider {
child: ListView.separated(
separatorBuilder: (context, index) =>
const SizedBox(height: 10.0),
itemCount: courses.length,
itemCount: courseEntries.length,
itemBuilder: (context, index) {
final course = courses[index];
final course = courseEntries[index].value;
final courseId = courseEntries[index].key;
return InkWell(
onTap: () => context.go(
spaceId != null
? '/rooms/spaces/$spaceId/addcourse/${courses[index].uuid}'
: '/${widget.route}/course/own/${course.uuid}',
? '/rooms/spaces/$spaceId/addcourse/$courseId'
: '/${widget.route}/course/own/$courseId',
),
borderRadius: BorderRadius.circular(12.0),
child: Container(
@ -195,7 +197,7 @@ class NewTripPageState extends State<NewTripPage> with CourseSearchProvider {
],
),
CourseInfoChips(
course,
courseId,
iconSize: 12.0,
fontSize: 12.0,
),

View file

@ -9,8 +9,9 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_plan_filter_widget.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart';
import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/spaces/utils/public_course_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -107,13 +108,17 @@ class PublicTripPageState extends State<PublicTripPage> {
}
try {
final searchResult = await CoursePlansRepo.search(
discoveredCourses.map((c) => c.courseId).toList(),
final resp = await CoursePlansRepo.search(
GetLocalizedCoursesRequest(
coursePlanIds: discoveredCourses.map((c) => c.courseId).toList(),
l1: MatrixState.pangeaController.languageController.activeL1Code()!,
),
);
final searchResult = resp.coursePlans;
coursePlans.clear();
for (final course in searchResult) {
coursePlans[course.uuid] = course;
for (final entry in searchResult.entries) {
coursePlans[entry.key] = entry.value;
}
} catch (e, s) {
ErrorHandler.logError(
@ -240,8 +245,8 @@ class PublicTripPageState extends State<PublicTripPage> {
}
final roomChunk = filteredCourses[index].room;
final course =
coursePlans[filteredCourses[index].courseId];
final courseId = filteredCourses[index].courseId;
final course = coursePlans[courseId];
final displayname = roomChunk.name ??
roomChunk.canonicalAlias ??
@ -249,7 +254,7 @@ class PublicTripPageState extends State<PublicTripPage> {
return InkWell(
onTap: () => context.go(
'/${widget.route}/course/public/${filteredCourses[index].courseId}',
'/${widget.route}/course/public/$courseId',
extra: roomChunk,
),
borderRadius: BorderRadius.circular(12.0),
@ -296,7 +301,7 @@ class PublicTripPageState extends State<PublicTripPage> {
),
if (course != null) ...[
CourseInfoChips(
course,
courseId,
iconSize: 12.0,
fontSize: 12.0,
),

View file

@ -1,93 +0,0 @@
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/payload_client/join_field.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';
/// Represents a course plan from the CMS API
class CmsCoursePlan {
static const String slug = "course-plans";
final String id;
final String title;
final String description;
final String cefrLevel;
final String l1; // Language of instruction
final String l2; // Target language
final JoinField? coursePlanMedia;
final JoinField? coursePlanTopics;
final String? coursePlanTranslationGroup;
final String? originalCoursePlan;
final PolymorphicRelationship? createdBy;
final PolymorphicRelationship? updatedBy;
final String updatedAt;
final String createdAt;
CmsCoursePlan({
required this.id,
required this.title,
required this.description,
required this.cefrLevel,
required this.l1,
required this.l2,
this.coursePlanMedia,
this.coursePlanTopics,
this.coursePlanTranslationGroup,
this.originalCoursePlan,
this.createdBy,
this.updatedBy,
required this.updatedAt,
required this.createdAt,
});
factory CmsCoursePlan.fromJson(Map<String, dynamic> json) {
return CmsCoursePlan(
id: json['id'],
title: json['title'],
description: json['description'],
cefrLevel: json['cefrLevel'],
l1: json['l1'],
l2: json['l2'],
coursePlanMedia: JoinField.fromJson(json['coursePlanMedia']),
coursePlanTopics: JoinField.fromJson(json['coursePlanTopics']),
coursePlanTranslationGroup: json['coursePlanTranslationGroup'],
originalCoursePlan: json['originalCoursePlan'],
createdBy: PolymorphicRelationship.fromJson(json['createdBy']),
updatedBy: PolymorphicRelationship.fromJson(json['updatedBy']),
updatedAt: json['updatedAt'],
createdAt: json['createdAt'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'cefrLevel': cefrLevel,
'l1': l1,
'l2': l2,
'coursePlanMedia': coursePlanMedia?.toJson(),
'coursePlanTopics': coursePlanTopics?.toJson(),
'coursePlanTranslationGroup': coursePlanTranslationGroup,
'originalCoursePlan': originalCoursePlan,
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'updatedAt': updatedAt,
'createdAt': createdAt,
};
}
CoursePlanModel toCoursePlanModel() {
return CoursePlanModel(
uuid: id,
targetLanguage: l2,
languageOfInstructions: l1,
cefrLevel: LanguageLevelTypeEnumExtension.fromString(cefrLevel),
title: title,
description: description,
mediaIds: coursePlanMedia?.docs ?? [],
topicIds: coursePlanTopics?.docs ?? [],
updatedAt: DateTime.parse(updatedAt),
createdAt: DateTime.parse(createdAt),
);
}
}

View file

@ -1,216 +0,0 @@
import 'package:fluffychat/pangea/activity_generator/media_enum.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/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/payload_client/join_field.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';
/// Represents a course plan activity role
class CmsCoursePlanActivityRole {
final String id;
final String name;
final String goal;
final String? avatarUrl;
CmsCoursePlanActivityRole({
required this.id,
required this.name,
required this.goal,
this.avatarUrl,
});
factory CmsCoursePlanActivityRole.fromJson(Map<String, dynamic> json) {
return CmsCoursePlanActivityRole(
id: json['id'] as String,
name: json['name'] as String,
goal: json['goal'] as String,
avatarUrl: json['avatarUrl'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'goal': goal,
'avatarUrl': avatarUrl,
};
}
ActivityRole toActivityRole() {
return ActivityRole(
id: id,
name: name,
goal: goal,
avatarUrl: avatarUrl,
);
}
}
/// Represents vocabulary in a course plan activity
class CmsCoursePlanVocab {
final String lemma;
final String pos;
final String? id;
CmsCoursePlanVocab({
required this.lemma,
required this.pos,
this.id,
});
factory CmsCoursePlanVocab.fromJson(Map<String, dynamic> json) {
return CmsCoursePlanVocab(
lemma: json['lemma'] as String,
pos: json['pos'] as String,
id: json['id'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'lemma': lemma,
'pos': pos,
'id': id,
};
}
}
/// Represents a course plan activity from the CMS API
class CmsCoursePlanActivity {
static const String slug = "course-plan-activities";
final String id;
final String title;
final String description;
final String learningObjective;
final String instructions;
final String l1; // Language of instruction
final String l2; // Target language
final String mode;
final LanguageLevelTypeEnum cefrLevel;
final List<CmsCoursePlanActivityRole> roles;
final List<CmsCoursePlanVocab> vocabs;
final JoinField? coursePlanActivityMedia;
final List<String> coursePlanTopics;
final String? coursePlanActivityTranslationGroup;
final String? originalCoursePlanActivity;
final PolymorphicRelationship? createdBy;
final PolymorphicRelationship? updatedBy;
final String updatedAt;
final String createdAt;
CmsCoursePlanActivity({
required this.id,
required this.title,
required this.description,
required this.learningObjective,
required this.instructions,
required this.l1,
required this.l2,
required this.mode,
required this.cefrLevel,
required this.roles,
required this.vocabs,
required this.coursePlanActivityMedia,
required this.coursePlanTopics,
this.coursePlanActivityTranslationGroup,
this.originalCoursePlanActivity,
this.createdBy,
this.updatedBy,
required this.updatedAt,
required this.createdAt,
});
factory CmsCoursePlanActivity.fromJson(Map<String, dynamic> json) {
return CmsCoursePlanActivity(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
learningObjective: json['learningObjective'] as String,
instructions: json['instructions'] as String,
l1: json['l1'] as String,
l2: json['l2'] as String,
mode: json['mode'] as String,
cefrLevel: LanguageLevelTypeEnumExtension.fromString(
json['cefrLevel'] as String,
),
roles: (json['roles'] as List<dynamic>)
.map(
(role) => CmsCoursePlanActivityRole.fromJson(
role as Map<String, dynamic>,
),
)
.toList(),
vocabs: (json['vocabs'] as List<dynamic>)
.map(
(vocab) =>
CmsCoursePlanVocab.fromJson(vocab as Map<String, dynamic>),
)
.toList(),
coursePlanActivityMedia:
JoinField.fromJson(json['coursePlanActivityMedia']),
coursePlanTopics: List<String>.from(json['coursePlanTopics']),
coursePlanActivityTranslationGroup:
json['coursePlanActivityTranslationGroup'] as String?,
originalCoursePlanActivity: json['originalCoursePlanActivity'] as String?,
createdBy: json['createdBy'] != null
? PolymorphicRelationship.fromJson(json['createdBy'])
: null,
updatedBy: json['updatedBy'] != null
? PolymorphicRelationship.fromJson(json['updatedBy'])
: null,
updatedAt: json['updatedAt'] as String,
createdAt: json['createdAt'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'learningObjective': learningObjective,
'instructions': instructions,
'l1': l1,
'l2': l2,
'mode': mode,
'cefrLevel': cefrLevel.string,
'roles': roles.map((role) => role.toJson()).toList(),
'vocabs': vocabs.map((vocab) => vocab.toJson()).toList(),
'coursePlanActivityMedia': coursePlanActivityMedia?.toJson(),
'coursePlanTopics': coursePlanTopics,
'coursePlanActivityTranslationGroup': coursePlanActivityTranslationGroup,
'originalCoursePlanActivity': originalCoursePlanActivity,
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'updatedAt': updatedAt,
'createdAt': createdAt,
};
}
ActivityPlanModel toActivityPlanModel(String? imageUrl) {
return ActivityPlanModel(
req: ActivityPlanRequest(
topic: "",
mode: mode,
objective: "",
media: MediaEnum.nan,
cefrLevel: cefrLevel,
languageOfInstructions: l1,
targetLanguage: l2,
numberOfParticipants: roles.length,
),
activityId: id,
title: title,
description: description,
learningObjective: learningObjective,
instructions: instructions,
vocab: vocabs.map((v) => Vocab(lemma: v.lemma, pos: v.pos)).toList(),
roles: Map.fromEntries(
roles.map((role) => MapEntry(role.id, role.toActivityRole())),
),
imageURL: imageUrl,
);
}
}

View file

@ -1,67 +0,0 @@
import 'package:fluffychat/pangea/payload_client/join_field.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';
/// Represents a course plan activity location from the CMS API
class CmsCoursePlanActivityLocation {
static const String slug = "course-plan-activity-locations";
final String id;
final String name;
// [longitude, latitude] - minItems: 2, maxItems: 2
final List<double>? coordinates;
final JoinField? coursePlanActivityLocationMedia;
final List<String> coursePlanActivities;
final PolymorphicRelationship? createdBy;
final PolymorphicRelationship? updatedBy;
final String updatedAt;
final String createdAt;
CmsCoursePlanActivityLocation({
required this.id,
required this.name,
this.coordinates,
this.coursePlanActivityLocationMedia,
required this.coursePlanActivities,
this.createdBy,
this.updatedBy,
required this.updatedAt,
required this.createdAt,
});
factory CmsCoursePlanActivityLocation.fromJson(Map<String, dynamic> json) {
return CmsCoursePlanActivityLocation(
id: json['id'] as String,
name: json['name'] as String,
coordinates: (json['coordinates'] as List<dynamic>?)
?.map((coord) => (coord as num).toDouble())
.toList(),
coursePlanActivityLocationMedia:
json['coursePlanActivityLocationMedia'] != null
? JoinField.fromJson(json['coursePlanActivityLocationMedia'])
: null,
coursePlanActivities: List<String>.from(json['coursePlanActivities']),
createdBy: json['createdBy'] != null
? PolymorphicRelationship.fromJson(json['createdBy'])
: null,
updatedBy: json['updatedBy'] != null
? PolymorphicRelationship.fromJson(json['updatedBy'])
: null,
updatedAt: json['updatedAt'] as String,
createdAt: json['createdAt'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'coordinates': coordinates,
'coursePlanActivities': coursePlanActivities,
'coursePlanActivityLocationMedia':
coursePlanActivityLocationMedia?.toJson(),
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'updatedAt': updatedAt,
'createdAt': createdAt,
};
}
}

View file

@ -1,98 +0,0 @@
import 'package:fluffychat/pangea/payload_client/image_sizes.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';
/// Represents a course plan activity location media from the CMS API
class CmsCoursePlanActivityLocationMedia {
static const String slug = "course-plan-activity-location-media";
final String id;
final String? alt;
final List<String> coursePlanActivityLocations;
final PolymorphicRelationship? createdBy;
final PolymorphicRelationship? updatedBy;
final String? prefix;
final String updatedAt;
final String createdAt;
final String? url;
final String? thumbnailURL;
final String? filename;
final String? mimeType;
final int? filesize;
final int? width;
final int? height;
final double? focalX;
final double? focalY;
final ImageSizes? sizes;
CmsCoursePlanActivityLocationMedia({
required this.id,
this.alt,
required this.coursePlanActivityLocations,
this.createdBy,
this.updatedBy,
this.prefix,
required this.updatedAt,
required this.createdAt,
this.url,
this.thumbnailURL,
this.filename,
this.mimeType,
this.filesize,
this.width,
this.height,
this.focalX,
this.focalY,
this.sizes,
});
factory CmsCoursePlanActivityLocationMedia.fromJson(
Map<String, dynamic> json,
) {
return CmsCoursePlanActivityLocationMedia(
id: json['id'],
alt: json['alt'],
coursePlanActivityLocations:
List<String>.from(json['coursePlanActivityLocations'] as List),
createdBy: json['createdBy'] != null
? PolymorphicRelationship.fromJson(json['createdBy'])
: null,
updatedBy: json['updatedBy'] != null
? PolymorphicRelationship.fromJson(json['updatedBy'])
: null,
prefix: json['prefix'],
updatedAt: json['updatedAt'],
createdAt: json['createdAt'],
url: json['url'],
thumbnailURL: json['thumbnailURL'],
filename: json['filename'],
mimeType: json['mimeType'],
filesize: json['filesize'],
width: json['width'],
height: json['height'],
focalX: json['focalX']?.toDouble(),
focalY: json['focalY']?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'alt': alt,
'coursePlanActivityLocations': coursePlanActivityLocations,
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'prefix': prefix,
'updatedAt': updatedAt,
'createdAt': createdAt,
'url': url,
'thumbnailURL': thumbnailURL,
'filename': filename,
'mimeType': mimeType,
'filesize': filesize,
'width': width,
'height': height,
'focalX': focalX,
'focalY': focalY,
'sizes': sizes?.toJson(),
};
}
}

View file

@ -1,97 +0,0 @@
import 'package:fluffychat/pangea/payload_client/image_sizes.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';
/// Represents course plan activity media from the CMS API
class CmsCoursePlanActivityMedia {
static const String slug = "course-plan-activity-media";
final String id;
final String? alt;
final List<String> coursePlanActivities;
final PolymorphicRelationship? createdBy;
final PolymorphicRelationship? updatedBy;
final String? prefix;
final String updatedAt;
final String createdAt;
final String? url;
final String? thumbnailURL;
final String? filename;
final String? mimeType;
final int? filesize;
final int? width;
final int? height;
final double? focalX;
final double? focalY;
final ImageSizes? sizes;
CmsCoursePlanActivityMedia({
required this.id,
this.alt,
required this.coursePlanActivities,
this.createdBy,
this.updatedBy,
this.prefix,
required this.updatedAt,
required this.createdAt,
this.url,
this.thumbnailURL,
this.filename,
this.mimeType,
this.filesize,
this.width,
this.height,
this.focalX,
this.focalY,
this.sizes,
});
factory CmsCoursePlanActivityMedia.fromJson(Map<String, dynamic> json) {
return CmsCoursePlanActivityMedia(
id: json['id'] as String,
alt: json['alt'] as String?,
coursePlanActivities: List<String>.from(json['coursePlanActivities']),
createdBy: json['createdBy'] != null
? PolymorphicRelationship.fromJson(json['createdBy'])
: null,
updatedBy: json['updatedBy'] != null
? PolymorphicRelationship.fromJson(json['updatedBy'])
: null,
prefix: json['prefix'] as String?,
updatedAt: json['updatedAt'] as String,
createdAt: json['createdAt'] as String,
url: json['url'] as String?,
thumbnailURL: json['thumbnailURL'] as String?,
filename: json['filename'] as String?,
mimeType: json['mimeType'] as String?,
filesize: json['filesize'] as int?,
width: json['width'] as int?,
height: json['height'] as int?,
focalX: (json['focalX'] as num?)?.toDouble(),
focalY: (json['focalY'] as num?)?.toDouble(),
sizes: json['sizes'] != null ? ImageSizes.fromJson(json['sizes']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'alt': alt,
'coursePlanActivities': coursePlanActivities,
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'prefix': prefix,
'updatedAt': updatedAt,
'createdAt': createdAt,
'url': url,
'thumbnailURL': thumbnailURL,
'filename': filename,
'mimeType': mimeType,
'filesize': filesize,
'width': width,
'height': height,
'focalX': focalX,
'focalY': focalY,
'sizes': sizes?.toJson(),
};
}
}

View file

@ -1,88 +0,0 @@
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/pangea/payload_client/join_field.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';
/// Represents a course plan topic from the CMS API
class CmsCoursePlanTopic {
static const String slug = "course-plan-topics";
final String id;
final String title;
final String description;
final JoinField? coursePlanActivities;
final JoinField? coursePlanTopicLocations;
final List<String> coursePlans;
final String? coursePlanTopicTranslationGroup;
final String? originalCoursePlanTopic;
final PolymorphicRelationship? createdBy;
final PolymorphicRelationship? updatedBy;
final String updatedAt;
final String createdAt;
CmsCoursePlanTopic({
required this.id,
required this.title,
required this.description,
required this.coursePlanActivities,
required this.coursePlanTopicLocations,
required this.coursePlans,
this.coursePlanTopicTranslationGroup,
this.originalCoursePlanTopic,
this.createdBy,
this.updatedBy,
required this.updatedAt,
required this.createdAt,
});
factory CmsCoursePlanTopic.fromJson(Map<String, dynamic> json) {
return CmsCoursePlanTopic(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
coursePlanActivities: JoinField.fromJson(
json['coursePlanActivities'],
),
coursePlanTopicLocations: JoinField.fromJson(
json['coursePlanTopicLocations'],
),
coursePlans: List<String>.from(json['coursePlans']),
coursePlanTopicTranslationGroup:
json['coursePlanTopicTranslationGroup'] as String?,
originalCoursePlanTopic: json['originalCoursePlanTopic'] as String?,
createdBy: json['createdBy'] != null
? PolymorphicRelationship.fromJson(json['createdBy'])
: null,
updatedBy: json['updatedBy'] != null
? PolymorphicRelationship.fromJson(json['updatedBy'])
: null,
updatedAt: json['updatedAt'] as String,
createdAt: json['createdAt'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'coursePlanActivities': coursePlanActivities?.toJson(),
'coursePlanTopicLocations': coursePlanTopicLocations?.toJson(),
'coursePlans': coursePlans,
'coursePlanTopicTranslationGroup': coursePlanTopicTranslationGroup,
'originalCoursePlanTopic': originalCoursePlanTopic,
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'updatedAt': updatedAt,
'createdAt': createdAt,
};
}
CourseTopicModel toCourseTopicModel() {
return CourseTopicModel(
title: title,
description: description,
uuid: id,
activityIds: coursePlanActivities?.docs ?? [],
locationIds: coursePlanTopicLocations?.docs ?? [],
);
}
}

View file

@ -1,4 +1,4 @@
import 'package:fluffychat/pangea/course_plans/course_location_model.dart';
import 'package:fluffychat/pangea/course_plans/course_locations/course_location_model.dart';
import 'package:fluffychat/pangea/payload_client/join_field.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';

View file

@ -1,44 +0,0 @@
import 'package:fluffychat/pangea/payload_client/join_field.dart';
import 'package:fluffychat/pangea/payload_client/polymorphic_relationship.dart';
/// Represents a course plan translation group from the CMS API
class CmsCoursePlanTranslationGroup {
static const String slug = "course-plans";
final String id;
final JoinField? coursePlans;
final PolymorphicRelationship? createdBy;
final PolymorphicRelationship? updatedBy;
final String updatedAt;
final String createdAt;
CmsCoursePlanTranslationGroup({
required this.id,
this.coursePlans,
this.createdBy,
this.updatedBy,
required this.updatedAt,
required this.createdAt,
});
factory CmsCoursePlanTranslationGroup.fromJson(Map<String, dynamic> json) {
return CmsCoursePlanTranslationGroup(
id: json['id'],
coursePlans: JoinField.fromJson(json['coursePlans']),
createdBy: PolymorphicRelationship.fromJson(json['createdBy']),
updatedBy: PolymorphicRelationship.fromJson(json['updatedBy']),
updatedAt: json['updatedAt'],
createdAt: json['createdAt'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'coursePlans': coursePlans?.toJson(),
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'updatedAt': updatedAt,
'createdAt': createdAt,
};
}
}