3671 dont show join wrap up state events instead in activity plan state event show taken untaken roles and below that show users who havent picked a role (#3675)

* chore: add role IDs

* chore: add row of unjoined users to activity plan state event display
This commit is contained in:
ggurdin 2025-08-08 16:37:33 -04:00 committed by GitHub
parent d87f86bee1
commit 194c25be25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 251 additions and 140 deletions

View file

@ -13,7 +13,7 @@ class ActivityPlanModel {
final String? imageURL;
final DateTime? endAt;
final Duration? duration;
final List<Role> roles;
final Map<String, ActivityRole> roles;
ActivityPlanModel({
required this.req,
@ -36,7 +36,7 @@ class ActivityPlanModel {
String? imageURL,
DateTime? endAt,
Duration? duration,
List<Role>? roles,
Map<String, ActivityRole>? roles,
}) {
return ActivityPlanModel(
req: req,
@ -55,6 +55,28 @@ class ActivityPlanModel {
final req =
ActivityPlanRequest.fromJson(json[ModelKey.activityPlanRequest]);
Map<String, ActivityRole> roles;
final roleContent = json['roles'];
if (roleContent is Map<String, dynamic>) {
roles = Map<String, ActivityRole>.from(
json['roles'].map(
(key, value) => MapEntry(
key,
ActivityRole.fromJson(value),
),
),
);
} else {
roles = {};
for (int i = 0; i < req.numberOfParticipants; i++) {
roles['role_$i'] = ActivityRole(
id: 'role_$i',
name: 'Participant',
avatarUrl: null,
);
}
}
return ActivityPlanModel(
imageURL: json[ModelKey.activityPlanImageURL],
instructions: json[ModelKey.activityPlanInstructions],
@ -74,15 +96,7 @@ class ActivityPlanModel {
minutes: json[ModelKey.activityPlanDuration]['minutes'] ?? 0,
)
: null,
roles: List<Role>.from(
json['roles']?.map((role) => Role.fromJson(role)) ??
req.numberOfParticipants > 1
? List.generate(
req.numberOfParticipants,
(index) => Role(name: 'Participant'),
)
: [Role(name: 'Participant')],
),
roles: roles,
);
}
@ -101,7 +115,9 @@ class ActivityPlanModel {
'hours': duration?.inHours.remainder(24) ?? 0,
'minutes': duration?.inMinutes.remainder(60) ?? 0,
},
'roles': roles.map((role) => role.toJson()).toList(),
'roles': roles.map(
(key, value) => MapEntry(key, value.toJson()),
),
};
}
@ -180,17 +196,20 @@ class Vocab {
int get hashCode => lemma.hashCode ^ pos.hashCode;
}
class Role {
class ActivityRole {
final String id;
final String name;
final String? avatarUrl;
Role({
ActivityRole({
required this.id,
required this.name,
this.avatarUrl,
});
factory Role.fromJson(Map<String, dynamic> json) {
return Role(
factory ActivityRole.fromJson(Map<String, dynamic> json) {
return ActivityRole(
id: json['id'],
name: json['name'],
avatarUrl: json['avatar_url'],
);
@ -198,6 +217,7 @@ class Role {
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'avatar_url': avatarUrl,
};

View file

@ -36,7 +36,15 @@ class BookmarkedActivitiesRepo {
for (final key in keys) {
final json = _bookStorage.read(key);
if (json == null) continue;
final activity = ActivityPlanModel.fromJson(json);
ActivityPlanModel? activity;
try {
activity = ActivityPlanModel.fromJson(json);
} catch (e) {
_bookStorage.remove(key);
continue;
}
if (key != activity.bookmarkId) {
_bookStorage.remove(key);
_bookStorage.write(activity.bookmarkId, activity.toJson());

View file

@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_participant_indicator.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_results_carousel.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
@ -49,6 +50,8 @@ class ActivityFinishedStatusMessageState
_setDefaultHighlightedRole();
}
Map<String, ActivityRole> get _roles => widget.room.activityPlan?.roles ?? {};
void _setExpanded(bool expanded) {
if (mounted) setState(() => _expanded = expanded);
}
@ -110,7 +113,7 @@ class ActivityFinishedStatusMessageState
}
final roles = widget.room.activityRoles;
return roles?.roles.where((role) {
return roles?.roles.values.where((role) {
return widget.room.activitySummary!.summary!.participants.any(
(p) => p.participantId == role.userId,
);
@ -202,20 +205,28 @@ class ActivityFinishedStatusMessageState
),
const SizedBox(height: 8.0),
Wrap(
alignment: WrapAlignment.center,
spacing: 12.0,
runSpacing: 12.0,
children: rolesWithSummaries
.map(
(role) => ActivityParticipantIndicator(
onTap: _highlightedRole == role
? null
: () => _highlightRole(role),
role: role,
displayname: role.userId.localpart,
selected: _highlightedRole == role,
),
)
.toList(),
children: rolesWithSummaries.map(
(role) {
final user =
widget.room.getParticipants().firstWhereOrNull(
(u) => u.id == role.userId,
);
return ActivityParticipantIndicator(
availableRole: _roles[role.id]!,
avatarUrl: _roles[role.id]?.avatarUrl ??
user?.avatarUrl?.toString(),
onTap: _highlightedRole == role
? null
: () => _highlightRole(role),
assignedRole: role,
selected: _highlightedRole == role,
);
},
).toList(),
),
const SizedBox(height: 20.0),
] else if (summary?.isLoading ?? false)

View file

@ -1,24 +1,33 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
class ActivityParticipantIndicator extends StatelessWidget {
final bool selected;
final ActivityRole availableRole;
final ActivityRoleModel? assignedRole;
final ActivityRoleModel? role;
final String? displayname;
final String? avatarUrl;
final VoidCallback? onTap;
final bool selected;
final double opacity;
const ActivityParticipantIndicator({
super.key,
required this.availableRole,
this.avatarUrl,
this.assignedRole,
this.selected = false,
this.role,
this.displayname,
this.onTap,
this.opacity = 1.0,
});
@override
@ -33,7 +42,7 @@ class ActivityParticipantIndicator extends StatelessWidget {
child: HoverBuilder(
builder: (context, hovered) {
return Opacity(
opacity: onTap == null ? 0.7 : 1.0,
opacity: opacity,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
@ -50,22 +59,43 @@ class ActivityParticipantIndicator extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 30.0,
backgroundColor: theme.colorScheme.primaryContainer,
),
assignedRole != null
? avatarUrl == null || avatarUrl!.startsWith("mxc")
? Avatar(
mxContent: avatarUrl != null
? Uri.parse(avatarUrl!)
: null,
name: assignedRole?.userId.localpart,
size: 60.0,
)
: ClipRRect(
borderRadius: BorderRadius.circular(30),
child: CachedNetworkImage(
imageUrl: avatarUrl!,
width: 60.0,
height: 60.0,
fit: BoxFit.cover,
),
)
: CircleAvatar(
radius: 30.0,
backgroundColor:
theme.colorScheme.primaryContainer,
),
Text(
role?.role ?? L10n.of(context).participant,
availableRole.name,
style: const TextStyle(
fontSize: 12.0,
),
),
Text(
displayname ?? L10n.of(context).openRoleLabel,
assignedRole?.userId.localpart ??
L10n.of(context).openRoleLabel,
style: TextStyle(
fontSize: 12.0,
color: displayname?.lightColorAvatar ??
role?.role?.lightColorAvatar,
color: assignedRole
?.userId.localpart?.lightColorAvatar ??
assignedRole?.role?.lightColorAvatar,
),
),
],

View file

@ -1,10 +1,12 @@
class ActivityRoleModel {
final String id;
final String userId;
final String? role;
DateTime? finishedAt;
DateTime? archivedAt;
ActivityRoleModel({
required this.id,
required this.userId,
this.role,
this.finishedAt,
@ -17,6 +19,7 @@ class ActivityRoleModel {
factory ActivityRoleModel.fromJson(Map<String, dynamic> json) {
return ActivityRoleModel(
id: json['id'],
userId: json['userId'],
role: json['role'],
finishedAt: json['finishedAt'] != null
@ -30,6 +33,7 @@ class ActivityRoleModel {
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'role': role,
'finishedAt': finishedAt?.toIso8601String(),
@ -45,7 +49,8 @@ class ActivityRoleModel {
other.userId == userId &&
other.role == role &&
other.finishedAt == finishedAt &&
other.archivedAt == archivedAt;
other.archivedAt == archivedAt &&
other.id == id;
}
@override
@ -53,5 +58,6 @@ class ActivityRoleModel {
userId.hashCode ^
role.hashCode ^
(finishedAt?.hashCode ?? 0) ^
(archivedAt?.hashCode ?? 0);
(archivedAt?.hashCode ?? 0) ^
id.hashCode;
}

View file

@ -1,67 +1,41 @@
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
class ActivityRolesModel {
final Event? event;
late List<ActivityRoleModel> _roles;
final Map<String, ActivityRoleModel> roles;
ActivityRolesModel({this.event, List<ActivityRoleModel>? roles}) {
assert(
event != null || roles != null,
"Either event or roles must be provided",
);
if (roles != null) {
_roles = roles;
} else {
final rolesList = event!.content["roles"] as List<dynamic>? ?? [];
try {
_roles = rolesList
.map<ActivityRoleModel>((e) => ActivityRoleModel.fromJson(e))
.toList();
} catch (e) {
_roles = [];
}
}
}
List<ActivityRoleModel> get roles => _roles;
const ActivityRolesModel(this.roles);
ActivityRoleModel? role(String userId) {
return _roles.firstWhereOrNull((r) => r.userId == userId);
return roles.values.firstWhereOrNull((r) => r.userId == userId);
}
/// If this user already has a role, replace it with the new one.
/// Otherwise, add the new role.
void updateRole(ActivityRoleModel role) {
final index = _roles.indexWhere((r) => r.userId == role.userId);
index != -1 ? _roles[index] = role : _roles.add(role);
roles[role.id] = role;
}
void finishAll() {
for (final role in _roles) {
if (role.isFinished) continue;
role.finishedAt = DateTime.now();
for (final id in roles.keys) {
if (roles[id]!.isFinished) continue;
roles[id]!.finishedAt = DateTime.now();
}
}
static ActivityRolesModel get empty => ActivityRolesModel(
roles: [],
);
static ActivityRolesModel get empty {
final roles = <String, ActivityRoleModel>{};
return ActivityRolesModel(roles);
}
Map<String, dynamic> toJson() {
return {
"roles": _roles.map((role) => role.toJson()).toList(),
"roles": roles.map((id, role) => MapEntry(id, role.toJson())),
};
}
static ActivityRolesModel fromJson(Map<String, dynamic> json) {
final roles = (json["roles"] as List<dynamic>?)
?.map<ActivityRoleModel>((e) => ActivityRoleModel.fromJson(e))
.toList();
return ActivityRolesModel(roles: roles);
final roles = (json['roles'] as Map<String, dynamic>)
.map((id, value) => MapEntry(id, ActivityRoleModel.fromJson(value)));
return ActivityRolesModel(roles);
}
}

View file

@ -1,4 +1,3 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:collection/collection.dart';
@ -36,13 +35,12 @@ extension ActivityRoomExtension on Room {
}
}
Future<void> joinActivity({
String? role,
}) async {
Future<void> joinActivity(ActivityRole role) async {
final currentRoles = activityRoles ?? ActivityRolesModel.empty;
final activityRole = ActivityRoleModel(
id: role.id,
userId: client.userID!,
role: role,
role: role.name,
);
currentRoles.updateRole(activityRole);
@ -277,7 +275,7 @@ extension ActivityRoomExtension on Room {
activityRoles?.role(client.userID!)?.isFinished ?? false;
bool get activityIsFinished {
final roles = activityRoles?.roles.where(
final roles = activityRoles?.roles.values.where(
(r) => r.userId != BotName.byEnvironment,
);
@ -294,14 +292,6 @@ extension ActivityRoomExtension on Room {
});
}
int get remainingRoles {
if (activityPlan == null) return 0;
return max(
0,
activityPlan!.roles.length - (activityRoles?.roles.length ?? 0),
);
}
bool get isHiddenActivityRoom =>
activityRoles?.role(client.userID!)?.isArchived ?? false;
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
@ -7,6 +8,7 @@ import 'package:fluffychat/pages/chat/events/state_message.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_participant_indicator.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
class ActivityStateEvent extends StatelessWidget {
final Event event;
@ -14,9 +16,20 @@ class ActivityStateEvent extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (event.room.activityPlan == null) {
return const SizedBox();
}
try {
final activity = ActivityPlanModel.fromJson(event.content);
final roles = event.room.activityRoles?.roles ?? [];
final availableRoles = event.room.activityPlan!.roles;
final assignedRoles = event.room.activityRoles?.roles ?? {};
final remainingMembers = event.room.getParticipants().where(
(p) => !assignedRoles.values.any((r) => r.userId == p.id),
);
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(
@ -33,17 +46,70 @@ class ActivityStateEvent extends StatelessWidget {
activity.markdown,
style: const TextStyle(fontSize: 14.0),
),
if (roles.isNotEmpty)
Wrap(
spacing: 12.0,
runSpacing: 12.0,
children: roles.map((role) {
return ActivityParticipantIndicator(
role: role,
displayname: role.userId.localpart,
);
}).toList(),
),
Wrap(
alignment: WrapAlignment.center,
spacing: 12.0,
runSpacing: 12.0,
children: availableRoles.values.map((availableRole) {
final assignedRole = assignedRoles[availableRole.id];
final user = event.room.getParticipants().firstWhereOrNull(
(u) => u.id == assignedRole?.userId,
);
return ActivityParticipantIndicator(
availableRole: availableRole,
assignedRole: assignedRole,
opacity: assignedRole == null || assignedRole.isFinished
? 0.5
: 1.0,
avatarUrl:
availableRole.avatarUrl ?? user?.avatarUrl.toString(),
);
}).toList(),
),
Wrap(
alignment: WrapAlignment.center,
spacing: 12.0,
runSpacing: 12.0,
children: remainingMembers.map((member) {
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(18.0),
),
padding: const EdgeInsets.all(4.0),
child: Opacity(
opacity: 0.5,
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
size: 18.0,
mxContent: member.avatarUrl,
name: member.calcDisplayname(),
userId: member.id,
),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 80.0,
),
child: Text(
member.calcDisplayname(),
style: TextStyle(
fontSize: 12.0,
color: theme.colorScheme.onPrimaryContainer,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
);
}).toList(),
),
],
),
);

View file

@ -22,34 +22,43 @@ class ActivityUnfinishedStatusMessage extends StatefulWidget {
class ActivityUnfinishedStatusMessageState
extends State<ActivityUnfinishedStatusMessage> {
int? _selectedRole;
String? _selectedRoleId;
void _selectRole(int role) {
if (_selectedRole == role) return;
if (mounted) setState(() => _selectedRole = role);
void _selectRole(String id) {
if (_selectedRoleId == id) return;
if (mounted) setState(() => _selectedRoleId = id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final remainingRoles = widget.room.remainingRoles;
final completed = widget.room.hasCompletedActivity;
final availableRoles = widget.room.activityPlan!.roles;
final assignedRoles = widget.room.activityRoles?.roles ?? {};
final remainingRoles = availableRoles.length - assignedRoles.length;
final unassignedIds = availableRoles.keys
.where((id) => !assignedRoles.containsKey(id))
.toList();
return Column(
children: [
if (!completed) ...[
if (remainingRoles > 0)
if (unassignedIds.isNotEmpty)
Wrap(
alignment: WrapAlignment.center,
spacing: 12.0,
runSpacing: 12.0,
children: List.generate(remainingRoles, (index) {
children: unassignedIds.map((id) {
return ActivityParticipantIndicator(
selected: _selectedRole == index,
onTap: () => _selectRole(index),
availableRole: availableRoles[id]!,
selected: _selectedRoleId == id,
onTap: () => _selectRole(id),
avatarUrl: availableRoles[id]?.avatarUrl,
);
}),
}).toList(),
),
const SizedBox(height: 16.0),
Text(
@ -79,11 +88,13 @@ class ActivityUnfinishedStatusMessageState
future: widget.room.continueActivity,
);
}
: _selectedRole != null
: _selectedRoleId != null
? () {
showFutureLoadingDialog(
context: context,
future: widget.room.joinActivity,
future: () => widget.room.joinActivity(
availableRoles[_selectedRoleId!]!,
),
);
}
: null,

View file

@ -22,9 +22,16 @@ class ActivitySearchRepo {
final cachedJson = _activityPlanStorage.read(request.storageKey);
if (cachedJson != null &&
(cachedJson['activity_plans'] as List).isNotEmpty) {
final cached = ActivityPlanResponse.fromJson(cachedJson);
ActivityPlanResponse? cached;
try {
cached = ActivityPlanResponse.fromJson(cachedJson);
} catch (e) {
_activityPlanStorage.remove(request.storageKey);
}
return cached;
if (cached != null) {
return cached;
}
}
final Requests req = Requests(

View file

@ -81,16 +81,4 @@ class ActivitySummaryRequestModel {
'content_feedback': contentFeedback.map((e) => e.toJson()).toList(),
};
}
factory ActivitySummaryRequestModel.fromJson(Map<String, dynamic> json) {
return ActivitySummaryRequestModel(
activity: ActivityPlanModel.fromJson(json['activity']),
activityResults: (json['activity_results'] as List)
.map((e) => ActivitySummaryResultsMessage.fromJson(e))
.toList(),
contentFeedback: (json['content_feedback'] as List)
.map((e) => ContentFeedbackModel.fromJson(e))
.toList(),
);
}
}