feat: level up summary (#2182)

* remove send local analytics to matrix on level up

* complete implementation of level up summary

* generated

* fix model key issues that prevents parsing request and response

* fix env

* generated

* improve level up summary to utilize existing state event

* generated

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: ggurdin <46800240+ggurdin@users.noreply.github.com>
This commit is contained in:
Wilson 2025-03-21 12:19:06 -04:00 committed by GitHub
parent bf102a33ef
commit b104069d31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 449 additions and 112 deletions

View file

@ -4814,8 +4814,18 @@
"home": "Home",
"join": "Join",
"learnByTexting": "Learn by texting",
"levelSummaryTrigger": "View summary",
"levelSummaryPopupTitle": "Level {level} Summary",
"@levelSummaryPopupTitle": {
"type": "String",
"placeholders": {
"level": {
"type": "int"
}
}
},
"startChatting": "Start chatting",
"referFriends": "Refer friends",
"referFriendDialogTitle": "Invite a friend to your conversation",
"referFriendDialogDesc": "Do you have a friend who is excited to learn a new language with you? Then copy and send this invitation link to join and start chatting with you today."
}
}

View file

@ -3793,5 +3793,15 @@
"@createASpace": {
"type": "String",
"placeholders": {}
},
"levelSummaryTrigger": "Đọc báo cáo",
"levelSummaryPopupTitle": "Tóm tắt cấp {level}",
"@levelSummaryPopupTitle": {
"type": "String",
"placeholders": {
"level": {
"type": "int"
}
}
}
}

View file

@ -365,18 +365,30 @@ class ChatController extends State<ChatPageWithRoom>
_levelSubscription = pangeaController.getAnalytics.stateStream
.where(
(update) =>
update is Map<String, dynamic> && update['level_up'] != null,
)
(update) => update is Map<String, dynamic> && update['level_up'] != null,
)
// .listen(
// (update) => Future.delayed(
// const Duration(milliseconds: 500),
// () => LevelUpUtil.showLevelUpDialog(
// update['level_up'],
// context,
// ),
// ),
// )
.listen(
(update) => Future.delayed(
const Duration(milliseconds: 500),
() => LevelUpUtil.showLevelUpDialog(
update['level_up'],
context,
),
),
// remove delay now that GetAnalyticsController._onLevelUp
// is async is should take roughly 500ms to make requests anyway
(update) {
LevelUpUtil.showLevelUpDialog(
update['level_up'],
update['analytics_room_id'],
update["construct_summary_state_event_id"],
update['construct_summary'],
context,
);
},
);
// Pangea#
_tryLoadTimeline();
if (kIsWeb) {

View file

@ -18,6 +18,8 @@ import 'package:fluffychat/pangea/common/constants/local.key.dart';
import 'package:fluffychat/pangea/common/controllers/base_controller.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
@ -141,8 +143,12 @@ class GetAnalyticsController extends BaseController {
if (analyticsUpdate.type == AnalyticsUpdateType.server) {
await _getConstructs(forceUpdate: true);
}
if (oldLevel < constructListModel.level) _onLevelUp();
if (oldLevel > constructListModel.level) await _onLevelDown(oldLevel);
if (oldLevel < constructListModel.level) {
await _onLevelUp(oldLevel, constructListModel.level);
}
if (oldLevel > constructListModel.level) {
await _onLevelDown(constructListModel.level, oldLevel);
}
_updateAnalyticsStream(origin: analyticsUpdate.origin);
// Update public profile each time that new analytics are added.
// If the level hasn't changed, this will not send an update to the server.
@ -158,13 +164,22 @@ class GetAnalyticsController extends BaseController {
}) =>
analyticsStream.add(AnalyticsStreamUpdate(origin: origin));
void _onLevelUp() {
setState({'level_up': constructListModel.level});
Future<void> _onLevelUp(final int lowerLevel, final int upperLevel) async {
final result = await _generateLevelUpAnalyticsAndSaveToStateEvent(
lowerLevel,
upperLevel,
);
setState({
'level_up': constructListModel.level,
'analytics_room_id': _client.analyticsRoomLocal(_l2!)?.id,
"construct_summary_state_event_id": result?.stateEventId,
"construct_summary": result?.summary,
});
}
Future<void> _onLevelDown(final prevLevel) async {
Future<void> _onLevelDown(final int lowerLevel, final int upperLevel) async {
final offset =
_calculateMinXpForLevel(prevLevel) - constructListModel.totalXP;
_calculateMinXpForLevel(lowerLevel) - constructListModel.totalXP;
await _pangeaController.userController.addXPOffset(offset);
constructListModel.updateConstructs(
[],
@ -344,6 +359,90 @@ class GetAnalyticsController extends BaseController {
);
_cache.add(entry);
}
Future<GenerateConstructSummaryResult?>
_generateLevelUpAnalyticsAndSaveToStateEvent(
final int lowerLevel,
final int upperLevel,
) async {
// generate level up analytics as a construct summary
ConstructSummary summary;
try {
final int maxXP = _calculateMinXpForLevel(upperLevel);
final int minXP = _calculateMinXpForLevel(lowerLevel);
int diffXP = maxXP - minXP;
if (diffXP < 0) diffXP = 0;
// compute construct use of current level
final List<OneConstructUse> constructUseOfCurrentLevel = [];
int score = 0;
for (final use in constructListModel.uses) {
constructUseOfCurrentLevel.add(use);
score += use.pointValue;
if (score >= diffXP) break;
}
// extract construct use message bodies for analytics
List<String?>? constructUseMessageContentBodies = [];
for (final use in constructUseOfCurrentLevel) {
try {
final useMessage = await use.getEvent(_client);
final useMessageBody = useMessage?.content["body"];
if (useMessageBody is String) {
constructUseMessageContentBodies.add(useMessageBody);
} else {
constructUseMessageContentBodies.add(null);
}
} catch (e) {
constructUseMessageContentBodies.add(null);
}
}
if (constructUseMessageContentBodies.length !=
constructUseOfCurrentLevel.length) {
constructUseMessageContentBodies = null;
}
final request = ConstructSummaryRequest(
constructs: constructUseOfCurrentLevel,
constructUseMessageContentBodies: constructUseMessageContentBodies,
language: _l2!.langCodeShort,
upperLevel: upperLevel,
lowerLevel: lowerLevel,
);
final response = await ConstructRepo.generateConstructSummary(request);
summary = response.summary;
} catch (e) {
debugPrint("Error generating level up analytics: $e");
ErrorHandler.logError(e: e, data: {'e': e});
return null;
}
String stateEventId;
try {
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) {
ErrorHandler.logError(
e: e,
data: {'e': e, 'message': "Analytics room not found for user"},
);
return null;
}
stateEventId = await _client.setRoomStateWithKey(
analyticsRoom.id,
PangeaEventTypes.constructSummary,
'',
summary.toJson(),
);
} catch (e) {
debugPrint("Error saving construct summary room: $e");
ErrorHandler.logError(e: e, data: {'e': e});
return null;
}
return GenerateConstructSummaryResult(
stateEventId: stateEventId,
summary: summary,
);
}
}
class AnalyticsCacheEntry {

View file

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
// New component renamed to ConstructSummaryAlertDialog with a max width
class ConstructSummaryAlertDialog extends StatelessWidget {
final String title;
final String content;
const ConstructSummaryAlertDialog({
super.key,
required this.title,
required this.content,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(title),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Text(content),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(L10n.of(context).close),
),
],
);
}
}
class LevelSummaryDialog extends StatelessWidget {
final int level;
final String analyticsRoomId;
final String summaryStateEventId;
final ConstructSummary? constructSummary;
const LevelSummaryDialog({
super.key,
required this.analyticsRoomId,
required this.level,
required this.summaryStateEventId,
this.constructSummary,
});
@override
Widget build(BuildContext context) {
final Client client = Matrix.of(context).client;
final futureSummary = client
.getOneRoomEvent(analyticsRoomId, summaryStateEventId)
.then((rawEvent) => ConstructSummary.fromJson(rawEvent.content));
if (constructSummary != null) {
return ConstructSummaryAlertDialog(
title: L10n.of(context).levelSummaryPopupTitle(level),
content: constructSummary!.textSummary,
);
} else {
return FutureBuilder<ConstructSummary>(
future: futureSummary,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return ConstructSummaryAlertDialog(
title: L10n.of(context).levelSummaryPopupTitle(level),
content: L10n.of(context).error502504Desc,
);
} else if (snapshot.hasData) {
final constructSummary = snapshot.data!;
return ConstructSummaryAlertDialog(
title: L10n.of(context).levelSummaryPopupTitle(level),
content: constructSummary.textSummary,
);
} else {
return const SizedBox.shrink();
}
},
);
}
}
}

View file

@ -9,10 +9,15 @@ import 'package:http/http.dart' as http;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'level_summary.dart';
class LevelUpUtil {
static void showLevelUpDialog(
int level,
String? analyticsRoomId,
String? summaryStateEventId,
ConstructSummary? constructSummary,
BuildContext context,
) {
final player = AudioPlayer();
@ -26,6 +31,9 @@ class LevelUpUtil {
context: context,
builder: (context) => LevelUpAnimation(
level: level,
analyticsRoomId: analyticsRoomId,
summaryStateEventId: summaryStateEventId,
constructSummary: constructSummary,
),
).then((_) => player.dispose());
}
@ -33,9 +41,17 @@ class LevelUpUtil {
class LevelUpAnimation extends StatefulWidget {
final int level;
final String? analyticsRoomId;
final String? summary;
final String? summaryStateEventId;
final ConstructSummary? constructSummary;
const LevelUpAnimation({
required this.level,
required this.analyticsRoomId,
this.summary,
this.summaryStateEventId,
this.constructSummary,
super.key,
});
@ -43,11 +59,7 @@ class LevelUpAnimation extends StatefulWidget {
LevelUpAnimationState createState() => LevelUpAnimationState();
}
class LevelUpAnimationState extends State<LevelUpAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<Offset> _slideAnimation;
class LevelUpAnimationState extends State<LevelUpAnimation> {
Uint8List? bytes;
final imageURL =
"${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpImageFileName}";
@ -55,52 +67,13 @@ class LevelUpAnimationState extends State<LevelUpAnimation>
@override
void initState() {
super.initState();
_loadImageData().then((resp) {
if (bytes == null) return;
_animationController.forward().then((_) {
if (mounted) Navigator.of(context).pop();
});
}).catchError((e) {
_loadImageData().catchError((e) {
if (mounted) Navigator.of(context).pop();
});
_animationController = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
_slideAnimation = TweenSequence<Offset>(
<TweenSequenceItem<Offset>>[
// Slide up from the bottom of the screen to the middle
TweenSequenceItem<Offset>(
tween: Tween<Offset>(begin: const Offset(0, 2), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeInOut)),
weight: 2.0, // Adjust weight for the duration of the slide-up
),
// Pause in the middle
TweenSequenceItem<Offset>(
tween: Tween<Offset>(begin: Offset.zero, end: Offset.zero)
.chain(CurveTween(curve: Curves.linear)),
weight: 8.0, // Adjust weight for the pause duration
),
// Slide up and off the screen
TweenSequenceItem<Offset>(
tween: Tween<Offset>(begin: Offset.zero, end: const Offset(0, -2))
.chain(CurveTween(curve: Curves.easeInOut)),
weight: 2.0, // Adjust weight for the slide-off duration
),
],
).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.linear, // Keep overall animation smooth
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@ -119,45 +92,67 @@ class LevelUpAnimationState extends State<LevelUpAnimation>
return const SizedBox();
}
Widget content = Image.memory(
bytes!,
height: kIsWeb ? 350 : 250,
);
if (!kIsWeb) {
content = OverflowBox(
maxWidth: double.infinity,
child: content,
);
}
return GestureDetector(
onDoubleTap: Navigator.of(context).pop,
child: Dialog.fullscreen(
backgroundColor: Colors.transparent,
child: Center(
child: SlideTransition(
position: _slideAnimation,
child: Stack(
alignment: Alignment.center,
children: [
content,
Padding(
padding: const EdgeInsets.only(top: 100),
child: Text(
L10n.of(context).levelPopupTitle(widget.level),
style: const TextStyle(
fontSize: kIsWeb ? 40 : 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
),
],
),
return Dialog(
backgroundColor: Colors.transparent,
child: Stack(
alignment: Alignment.center,
children: [
// Banner image
Image.memory(
bytes!,
height: kIsWeb ? 350 : 250,
width: double.infinity,
fit: BoxFit.cover,
),
),
// Overlay: centered title and close button
Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(
top: kIsWeb ? 200 : 100,
), // Added hardcoded padding above the text
child: Text(
L10n.of(context).levelPopupTitle(widget.level),
style: const TextStyle(
fontSize: kIsWeb ? 40 : 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(L10n.of(context).close),
),
const SizedBox(width: 16),
if (widget.summaryStateEventId != null &&
widget.analyticsRoomId != null)
// Show summary button
ElevatedButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => LevelSummaryDialog(
level: widget.level,
analyticsRoomId: widget.analyticsRoomId!,
summaryStateEventId: widget.summaryStateEventId!,
constructSummary: widget.constructSummary,
),
);
},
child: Text(L10n.of(context).levelSummaryTrigger),
),
],
),
],
),
],
),
);
}

View file

@ -304,18 +304,13 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
sendLocalAnalyticsToAnalyticsRoom();
return;
}
final int newLevel =
_pangeaController.getAnalytics.constructListModel.level;
newLevel > prevLevel
? sendLocalAnalyticsToAnalyticsRoom()
: analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.local,
newConstructs,
origin: origin,
),
);
analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.local,
newConstructs,
origin: origin,
),
);
}
/// Clears the local cache of recently sent constructs. Called before updating analytics

View file

@ -76,6 +76,8 @@ class PApiUrls {
"${PApiUrls.choreoEndpoint}/activity_plan/search";
static String morphFeaturesAndTags = "${PApiUrls.choreoEndpoint}/morphs";
static String constructSummary =
"${PApiUrls.choreoEndpoint}/construct_summary";
///-------------------------------- revenue cat --------------------------
static String rcAppsChoreo = "${PApiUrls.subscriptionEndpoint}/app_ids";

View file

@ -0,0 +1,126 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ConstructSummary {
final int upperLevel;
final int lowerLevel;
final String language;
final String textSummary;
ConstructSummary({
required this.upperLevel,
required this.lowerLevel,
required this.language,
required this.textSummary,
});
Map<String, dynamic> toJson() {
return {
'upper_level': upperLevel,
'lower_level': lowerLevel,
'language': language,
'text_summary': textSummary,
};
}
factory ConstructSummary.fromJson(Map<String, dynamic> json) {
return ConstructSummary(
upperLevel: json['upper_level'],
lowerLevel: json['lower_level'],
language: json['language'],
textSummary: json['text_summary'],
);
}
}
class ConstructSummaryRequest {
final List<OneConstructUse> constructs;
final List<String?>? constructUseMessageContentBodies;
final String language;
final int upperLevel;
final int lowerLevel;
ConstructSummaryRequest({
required this.constructs,
this.constructUseMessageContentBodies,
required this.language,
required this.upperLevel,
required this.lowerLevel,
});
Map<String, dynamic> toJson() {
return {
'constructs': constructs.map((construct) => construct.toJson()).toList(),
'construct_use_message_content_bodies': constructUseMessageContentBodies,
'language': language,
'upper_level': upperLevel,
'lower_level': lowerLevel,
};
}
factory ConstructSummaryRequest.fromJson(Map<String, dynamic> json) {
return ConstructSummaryRequest(
constructs: (json['constructs'] as List)
.map((construct) => OneConstructUse.fromJson(construct))
.toList(),
constructUseMessageContentBodies:
List<String>.from(json['construct_use_message_content_bodies']),
language: json['language'],
upperLevel: json['upper_level'],
lowerLevel: json['lower_level'],
);
}
}
class ConstructSummaryResponse {
final ConstructSummary summary;
ConstructSummaryResponse({
required this.summary,
});
Map<String, dynamic> toJson() {
return {
'summary': summary.toJson(),
};
}
factory ConstructSummaryResponse.fromJson(Map<String, dynamic> json) {
return ConstructSummaryResponse(
summary: ConstructSummary.fromJson(json['summary']),
);
}
}
class GenerateConstructSummaryResult {
final String stateEventId;
final ConstructSummary summary;
GenerateConstructSummaryResult({
required this.stateEventId,
required this.summary,
});
}
class ConstructRepo {
static Future<ConstructSummaryResponse> generateConstructSummary(
ConstructSummaryRequest request,
) async {
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res =
await req.post(url: PApiUrls.constructSummary, body: request.toJson());
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = ConstructSummaryResponse.fromJson(decodedBody);
return response;
}
}

View file

@ -8,6 +8,7 @@ class PangeaEventTypes {
// static const studentAnalyticsSummary = "pangea.usranalytics";
static const summaryAnalytics = "pangea.summaryAnalytics";
static const construct = "pangea.construct";
static const constructSummary = "pangea.construct_summary";
static const userChosenEmoji = "p.emoji";
static const translation = "pangea.translation";