5759 if session filled redirect from choose role (#5815)

* move activity session page bottom section into its own widget

* activity session full mode

* fix loading of activity session preview on open app via course ping
This commit is contained in:
ggurdin 2026-02-26 10:35:24 -05:00 committed by GitHub
parent e1b9a21e1c
commit 1caa74c668
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 339 additions and 327 deletions

View file

@ -5353,5 +5353,8 @@
"denyKnockChat": "User will be rejected from chat, however they may attempt to rejoin any time later.",
"banFromSpace": "Ban from course",
"unbanFromSpace": "Unban from course",
"cannotJoinBannedRoom": "Banned. Unable to join."
"cannotJoinBannedRoom": "Banned. Unable to join.",
"sessionFull": "Too late! This activity is full.",
"returnToCourse": "Return to course",
"returnHome": "Return home"
}

View file

@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
class ActivitySessionButtonWidget extends StatelessWidget {
final ActivitySessionStartController controller;
const ActivitySessionButtonWidget({super.key, required this.controller});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedSize(
duration: FluffyThemes.animationDuration,
child: Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: theme.dividerColor)),
color: theme.colorScheme.surface,
),
padding: const EdgeInsets.all(24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.maxTimelineWidth,
),
child: Column(
spacing: 16.0,
children: [
if (controller.descriptionText != null)
Text(
controller.descriptionText!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
switch (controller.state) {
SessionState.notStarted => _ActivityStartButtons(
controller,
),
SessionState.confirmedRole =>
_ActivityRoleConfirmedButtons(controller: controller),
SessionState.selectedSessionFull => _CTAButton(
controller.courseParent != null
? L10n.of(context).returnToCourse
: L10n.of(context).returnHome,
controller.returnFromFullSession,
),
_ => _CTAButton(
controller.activityRoom?.isRoomAdmin ?? true
? L10n.of(context).start
: L10n.of(context).confirm,
controller.confirmRoleSelection,
),
},
],
),
),
),
],
),
),
);
}
}
class _ActivityStartButtons extends StatelessWidget {
final ActivitySessionStartController controller;
const _ActivityStartButtons(this.controller);
@override
Widget build(BuildContext context) {
final hasActiveSession = controller.canJoinExistingSession;
return FutureBuilder(
future: controller.neededCourseParticipants(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LinearProgressIndicator();
}
final int neededParticipants = snapshot.data ?? 0;
final bool hasEnoughParticipants = neededParticipants <= 0;
return Column(
spacing: 16.0,
children: [
if (!hasEnoughParticipants) ...[
Text(
neededParticipants > 1
? L10n.of(context).activityNeedsMembers(neededParticipants)
: L10n.of(context).activityNeedsOneMember,
textAlign: TextAlign.center,
),
_CTAButton(
L10n.of(context).inviteFriendsToCourse,
controller.inviteToCourse,
),
_CTAButton(
L10n.of(context).pickDifferentActivity,
controller.goToCourse,
),
] else if (controller.joinedActivityRoomId != null) ...[
_CTAButton(
L10n.of(context).continueText,
controller.goToJoinedActivity,
),
] else ...[
_CTAButton(
hasActiveSession
? L10n.of(context).startNewSession
: L10n.of(context).start,
controller.startNewActivity,
),
if (hasActiveSession)
_CTAButton(
L10n.of(context).joinOpenSession,
controller.joinExistingSession,
),
],
],
);
},
);
}
}
class _ActivityRoleConfirmedButtons extends StatelessWidget {
final ActivitySessionStartController controller;
const _ActivityRoleConfirmedButtons({required this.controller});
@override
Widget build(BuildContext context) {
final showPingCourse = controller.courseParent != null;
final canPingCourse = controller.canPingParticipants;
final showInviteOptions = controller.activityRoom?.isRoomAdmin == true;
final showPlayWithBot = !controller.isBotRoomMember;
return Column(
mainAxisSize: .min,
children: [
if (showPingCourse)
_CTAButton(
L10n.of(context).pingParticipants,
canPingCourse ? controller.pingCourse : null,
),
if (showInviteOptions && showPlayWithBot)
_CTAButton(L10n.of(context).playWithBot, controller.playWithBot),
if (showInviteOptions)
_CTAButton(L10n.of(context).inviteFriends, controller.inviteFriends),
],
);
}
}
class _CTAButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
const _CTAButton(this.text, this.onPressed);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.all(8.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
),
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Flexible(child: Text(text, textAlign: TextAlign.center))],
),
);
}
}

View file

@ -33,6 +33,9 @@ enum SessionState {
/// The room has been created but the user hasn't selected a role yet. Non-admins haven't joined yet.
notSelectedRole,
/// The room has been created and all roles are full. The user cannot get a role in this session.
selectedSessionFull,
/// The user has selected a role but hasn't confirmed yet. Non-admins haven't joined yet.
selectedRole,
@ -86,9 +89,7 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
_selectedRoleId = null;
showInstructions = false;
});
}
if (oldWidget.activityId != widget.activityId) {
_load();
}
}
@ -115,22 +116,31 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
false;
SessionState get state {
// the room exists and user has set their role
if (activityRoom?.membership == Membership.join &&
activityRoom?.hasPickedRole == true) {
return SessionState.confirmedRole;
}
// the user has selected a role but hasn't confirmed yet
if (_selectedRoleId != null) {
return SessionState.selectedRole;
}
// the room either doesn't exist or the user hasn't joined it yet
if (activityRoom == null || activityRoom!.membership != Membership.join) {
// If the room does exist or is being created, then user needs to select a role.
// Else (room doesn't exist and user hasn't started creating it), then not started.
return widget.roomId != null || widget.launch
? SessionState.notSelectedRole
? canSelectRole
? SessionState.notSelectedRole
: SessionState.selectedSessionFull
: SessionState.notStarted;
}
return SessionState.notSelectedRole;
return canSelectRole
? SessionState.notSelectedRole
: SessionState.selectedSessionFull;
}
String? get descriptionText {
@ -151,12 +161,11 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
return activityRoom?.isRoomAdmin ?? false
? L10n.of(context).chooseRole
: L10n.of(context).chooseRoleToParticipate;
case SessionState.selectedSessionFull:
return L10n.of(context).sessionFull;
}
}
bool get enableButtons =>
[SessionState.notStarted, SessionState.selectedRole].contains(state);
Map<String, ActivityRoleModel> get assignedRoles {
if (activityRoom != null && activityRoom!.membership == Membership.join) {
return activityRoom!.assignedRoles ?? {};
@ -164,6 +173,12 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
return roomSummaries?[widget.roomId]?.joinedUsersWithRoles ?? {};
}
bool get canSelectRole {
final assigned = assignedRoles.length;
final total = activity?.roles.length ?? 0;
return assigned < total;
}
bool canSelectParticipant(String id) {
if (state == SessionState.confirmedRole ||
state == SessionState.notStarted) {
@ -311,13 +326,19 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
}
Future<void> _loadSummary() async {
if (courseParent == null) return;
await loadRoomSummaries(
courseParent!.spaceChildren
.map((c) => c.roomId)
.whereType<String>()
.toList(),
);
final Set<String> roomIds = {};
if (widget.roomId != null) {
roomIds.add(widget.roomId!);
}
if (courseParent != null) {
roomIds.addAll(
courseParent!.spaceChildren.map((c) => c.roomId).whereType<String>(),
);
}
if (roomIds.isEmpty) return;
await loadRoomSummaries(roomIds.toList());
}
Future<void> _loadActivity() async {
@ -412,7 +433,18 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
}
}
Future<String> joinExistingSession() async {
Future<void> joinExistingSession() async {
final resp = await showFutureLoadingDialog(
context: context,
future: _joinExistingSession,
);
if (!resp.isError) {
NavigationUtil.goToSpaceRoute(resp.result, [], context);
}
}
Future<String> _joinExistingSession() async {
if (!canJoinExistingSession) {
throw Exception("No existing session to join");
}
@ -476,7 +508,10 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
}
}
Future<void> pingCourse() async {
Future<void> pingCourse() =>
showFutureLoadingDialog(context: context, future: _pingCourse);
Future<void> _pingCourse() async {
if (activityRoom?.courseParent == null) {
throw Exception("Activity is not part of a course");
}
@ -546,6 +581,34 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
await future.timeout(const Duration(seconds: 5));
}
void inviteFriends() {
if (activityRoom == null) return;
NavigationUtil.goToSpaceRoute(activityRoom!.id, ['invite'], context);
}
void inviteToCourse() {
if (courseParent == null) return;
context.push("/rooms/spaces/${courseParent!.id}/invite");
}
void goToCourse() {
if (courseParent == null) return;
context.push("/rooms/spaces/${courseParent!.id}/details?tab=course");
}
void goToJoinedActivity() {
if (joinedActivityRoomId == null) return;
NavigationUtil.goToSpaceRoute(joinedActivityRoomId!, [], context);
}
void returnFromFullSession() {
if (courseParent != null) {
goToCourse();
} else {
NavigationUtil.goToSpaceRoute(null, [], context);
}
}
@override
Widget build(BuildContext context) => ActivitySessionStartView(this);
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
@ -10,6 +9,7 @@ import 'package:fluffychat/pangea/activity_feedback/activity_feedback_repo.dart'
import 'package:fluffychat/pangea/activity_feedback/activity_feedback_request.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_session_button_widget.dart';
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/chat_settings/utils/room_summary_extension.dart';
@ -20,7 +20,6 @@ import 'package:fluffychat/pangea/course_chats/open_roles_indicator.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/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/navigation/navigation_util.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -30,16 +29,60 @@ class ActivitySessionStartView extends StatelessWidget {
final ActivitySessionStartController controller;
const ActivitySessionStartView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final buttonStyle = ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.all(8.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)),
Future<void> _submitActivityFeedback(BuildContext context) async {
final feedback = await showDialog<String?>(
context: context,
builder: (context) {
return FeedbackDialog(
title: L10n.of(context).feedbackTitle,
onSubmit: (feedback) {
Navigator.of(context).pop(feedback);
},
scrollable: false,
);
},
);
if (feedback == null || feedback.isEmpty) {
return;
}
final resp = await showFutureLoadingDialog(
context: context,
future: () => ActivityFeedbackRepo.submitFeedback(
ActivityFeedbackRequest(
activityId: controller.widget.activityId,
feedbackText: feedback,
userId: Matrix.of(context).client.userID!,
userL1: MatrixState.pangeaController.userController.userL1Code!,
userL2: MatrixState.pangeaController.userController.userL2Code!,
),
),
);
if (resp.isError) {
return;
}
CourseActivityRepo.setSentFeedback(
controller.widget.activityId,
MatrixState.pangeaController.userController.userL1Code!,
);
await showDialog(
context: context,
builder: (context) {
return FeedbackResponseDialog(
title: L10n.of(context).feedbackTitle,
feedback: resp.result!.userFriendlyResponse,
description: L10n.of(context).feedbackRespDesc,
);
},
);
}
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: Matrix.of(
context,
@ -73,63 +116,7 @@ class ActivitySessionStartView extends StatelessWidget {
actions: [
IconButton(
icon: const Icon(Icons.flag_outlined),
onPressed: () async {
final feedback = await showDialog<String?>(
context: context,
builder: (context) {
return FeedbackDialog(
title: L10n.of(context).feedbackTitle,
onSubmit: (feedback) {
Navigator.of(context).pop(feedback);
},
scrollable: false,
);
},
);
if (feedback == null || feedback.isEmpty) {
return;
}
final resp = await showFutureLoadingDialog(
context: context,
future: () => ActivityFeedbackRepo.submitFeedback(
ActivityFeedbackRequest(
activityId: controller.widget.activityId,
feedbackText: feedback,
userId: Matrix.of(context).client.userID!,
userL1: MatrixState
.pangeaController
.userController
.userL1Code!,
userL2: MatrixState
.pangeaController
.userController
.userL2Code!,
),
),
);
if (resp.isError) {
return;
}
CourseActivityRepo.setSentFeedback(
controller.widget.activityId,
MatrixState.pangeaController.userController.userL1Code!,
);
await showDialog(
context: context,
builder: (context) {
return FeedbackResponseDialog(
title: L10n.of(context).feedbackTitle,
feedback: resp.result!.userFriendlyResponse,
description: L10n.of(context).feedbackRespDesc,
);
},
);
},
onPressed: () => _submitActivityFeedback(context),
),
],
),
@ -180,142 +167,7 @@ class ActivitySessionStartView extends StatelessWidget {
),
),
),
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.dividerColor),
),
color: theme.colorScheme.surface,
),
padding: const EdgeInsets.all(24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.maxTimelineWidth,
),
child: Column(
spacing: 16.0,
children: [
if (controller.descriptionText != null)
Text(
controller.descriptionText!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
if (controller.state ==
SessionState.notStarted)
_ActivityStartButtons(
controller,
buttonStyle,
)
else if (controller.state ==
SessionState.confirmedRole) ...[
if (controller.courseParent != null)
ElevatedButton(
style: buttonStyle,
onPressed:
controller.canPingParticipants
? () {
showFutureLoadingDialog(
context: context,
future:
controller.pingCourse,
);
}
: null,
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Flexible(
child: Text(
L10n.of(
context,
).pingParticipants,
textAlign: TextAlign.center,
),
),
],
),
),
if (controller
.activityRoom!
.isRoomAdmin) ...[
if (!controller.isBotRoomMember)
ElevatedButton(
style: buttonStyle,
onPressed: controller.playWithBot,
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
L10n.of(
context,
).playWithBot,
),
],
),
),
ElevatedButton(
style: buttonStyle,
onPressed: () {
NavigationUtil.goToSpaceRoute(
controller.activityRoom!.id,
['invite'],
context,
);
},
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
L10n.of(
context,
).inviteFriends,
),
],
),
),
],
] else
ElevatedButton(
style: buttonStyle,
onPressed: controller.enableButtons
? controller.confirmRoleSelection
: null,
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
controller
.activityRoom
?.isRoomAdmin ??
true
? L10n.of(context).start
: L10n.of(context).confirm,
),
],
),
),
],
),
),
),
],
),
),
),
ActivitySessionButtonWidget(controller: controller),
],
),
),
@ -325,113 +177,6 @@ class ActivitySessionStartView extends StatelessWidget {
}
}
class _ActivityStartButtons extends StatelessWidget {
final ActivitySessionStartController controller;
final ButtonStyle buttonStyle;
const _ActivityStartButtons(this.controller, this.buttonStyle);
@override
Widget build(BuildContext context) {
final hasActiveSession = controller.canJoinExistingSession;
final joinedActivityRoom = controller.joinedActivityRoomId;
return FutureBuilder(
future: controller.neededCourseParticipants(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LinearProgressIndicator();
}
final int neededParticipants = snapshot.data ?? 0;
final bool hasEnoughParticipants = neededParticipants <= 0;
return Column(
spacing: 16.0,
children: [
if (!hasEnoughParticipants) ...[
Text(
neededParticipants > 1
? L10n.of(context).activityNeedsMembers(neededParticipants)
: L10n.of(context).activityNeedsOneMember,
textAlign: TextAlign.center,
),
ElevatedButton(
style: buttonStyle,
onPressed: controller.courseParent?.canInvite ?? false
? () => context.push(
"/rooms/spaces/${controller.courseParent!.id}/invite",
)
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text(L10n.of(context).inviteFriendsToCourse)],
),
),
ElevatedButton(
style: buttonStyle,
onPressed: () => context.push(
"/rooms/spaces/${controller.courseParent!.id}/details?tab=course",
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text(L10n.of(context).pickDifferentActivity)],
),
),
] else if (joinedActivityRoom != null) ...[
ElevatedButton(
style: buttonStyle,
onPressed: () {
NavigationUtil.goToSpaceRoute(
joinedActivityRoom,
[],
context,
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text(L10n.of(context).continueText)],
),
),
] else ...[
ElevatedButton(
style: buttonStyle,
onPressed: controller.startNewActivity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
hasActiveSession
? L10n.of(context).startNewSession
: L10n.of(context).start,
),
],
),
),
if (hasActiveSession)
ElevatedButton(
style: buttonStyle,
onPressed: () async {
final resp = await showFutureLoadingDialog(
context: context,
future: controller.joinExistingSession,
);
if (!resp.isError) {
NavigationUtil.goToSpaceRoute(resp.result, [], context);
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text(L10n.of(context).joinOpenSession)],
),
),
],
],
);
},
);
}
}
class _ActivityStatuses extends StatelessWidget {
final Map<ActivitySummaryStatus, Map<String, RoomSummaryResponse>> statuses;
final Room space;

View file

@ -88,6 +88,7 @@ class BackgroundPush {
// Handle notifications when app is opened from terminated/background state
FirebaseMessaging.instance.getInitialMessage().then(_onOpenNotification);
FirebaseMessaging.onMessageOpenedApp.listen(_onOpenNotification);
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
// Pangea#
mainIsolateReceivePort?.listen((message) async {
try {
@ -631,3 +632,15 @@ class UPFunctions extends UnifiedPushFunctions {
await UnifiedPush.saveDistributor(distributor);
}
}
// #Pangea
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Required for background isolate
WidgetsFlutterBinding.ensureInitialized();
final instance = BackgroundPush._instance;
if (instance == null) return;
await instance._onOpenNotification(message);
}
// Pangea#