chore: fully update match info after auto-accepting replacement, add … (#2866)

* chore: fully update match info after auto-accepting replacement, add more error handling in construct token span

* bump version

* fix: don't stop activity language on fail to fetch image URL

* fix: don't show copy class code buttons into class code is null

* fix: use activity type enum name in key instead of string
This commit is contained in:
ggurdin 2025-05-22 13:00:42 -04:00 committed by GitHub
parent a24ee27f9b
commit e96a16b297
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 236 additions and 146 deletions

View file

@ -867,10 +867,13 @@ class ChatController extends State<ChatPageWithRoom>
pangeaEditingEvent = previousEdit;
}
GoogleAnalytics.sendMessage(
room.id,
room.classCode(context),
);
final spaceCode = room.classCode(context);
if (spaceCode != null) {
GoogleAnalytics.sendMessage(
room.id,
spaceCode,
);
}
if (msgEventId == null) {
ErrorHandler.logError(

View file

@ -63,98 +63,104 @@ class InvitationSelectionView extends StatelessWidget {
child: Column(
children: [
// #Pangea
Padding(
padding: const EdgeInsets.all(16.0),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
onTap: () async {
await Clipboard.setData(
ClipboardData(text: room.classCode(context)),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).copiedToClipboard)),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 16.0,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
if (room.isSpace && room.classCode(context) != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
child: Row(
spacing: 16.0,
children: [
const Icon(
Icons.copy_outlined,
size: 20.0,
onTap: () async {
await Clipboard.setData(
ClipboardData(text: room.classCode(context)!),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).copiedToClipboard),
),
Text(
"${L10n.of(context).copyClassCode}: ${room.classCode(context)}",
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
fontSize: 16.0,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 16.0,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(99),
),
child: Row(
spacing: 16.0,
children: [
const Icon(
Icons.copy_outlined,
size: 20.0,
),
),
],
Text(
"${L10n.of(context).copyClassCode}: ${room.classCode(context)}",
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
fontSize: 16.0,
),
),
],
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(
bottom: 16.0,
left: 16.0,
right: 16.0,
),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
if (room.isSpace && room.classCode(context) != null)
Padding(
padding: const EdgeInsets.only(
bottom: 16.0,
left: 16.0,
right: 16.0,
),
onTap: () async {
final String initialUrl =
kIsWeb ? html.window.origin! : Environment.frontendURL;
final link =
"$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode(context)}";
await Clipboard.setData(ClipboardData(text: link));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).copiedToClipboard)),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 16.0,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
child: Row(
spacing: 16.0,
children: [
const Icon(
Icons.copy_outlined,
size: 20.0,
onTap: () async {
final String initialUrl =
kIsWeb ? html.window.origin! : Environment.frontendURL;
final link =
"$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode(context)}";
await Clipboard.setData(ClipboardData(text: link));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).copiedToClipboard),
),
Text(
L10n.of(context).copyClassLink,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
fontSize: 16.0,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 16.0,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(99),
),
child: Row(
spacing: 16.0,
children: [
const Icon(
Icons.copy_outlined,
size: 20.0,
),
),
],
Text(
L10n.of(context).copyClassLink,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
fontSize: 16.0,
),
),
],
),
),
),
),
),
// Pangea#
Padding(
// #Pangea

View file

@ -237,7 +237,10 @@ class NewGroupController extends State<NewGroup> {
room = client.getRoomById(spaceId);
}
if (room == null) return;
GoogleAnalytics.createClass(room.name, room.classCode(context));
final spaceCode = room.classCode(context);
if (spaceCode != null) {
GoogleAnalytics.createClass(room.name, spaceCode);
}
try {
await room.invite(BotName.byEnvironment);
} catch (err) {

View file

@ -174,12 +174,25 @@ class ActivityPlanCardState extends State<ActivityPlanCard> {
}
Future<void> _setAvatarByImageURL() async {
if (_avatar != null || _imageURL == null) return;
final resp = await http
.get(Uri.parse(_imageURL!))
.timeout(const Duration(seconds: 5));
if (mounted) {
setState(() => _avatar = resp.bodyBytes);
try {
if (_avatar != null || _imageURL == null) return;
final resp = await http
.get(Uri.parse(_imageURL!))
.timeout(const Duration(seconds: 5));
if (mounted) {
setState(() => _avatar = resp.bodyBytes);
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"imageURL": _imageURL,
},
);
if (mounted) {
setState(() => _avatar = null);
}
}
}

View file

@ -19,6 +19,9 @@ class ClassInvitationButtons extends StatelessWidget {
Widget build(BuildContext context) {
final Room? room = Matrix.of(context).client.getRoomById(roomId);
if (room == null) return Text(L10n.of(context).oopsSomethingWentWrong);
if (room.classCode(context) == null) {
return const SizedBox();
}
final copyClassLinkListTile = ListTile(
title: Text(
@ -67,7 +70,7 @@ class ClassInvitationButtons extends StatelessWidget {
onTap: () async {
//PTODO-Lala: Standarize toast
//PTODO - explore using Fluffyshare for this
await Clipboard.setData(ClipboardData(text: room.classCode(context)));
await Clipboard.setData(ClipboardData(text: room.classCode(context)!));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).copiedToClipboard)),
);

View file

@ -456,11 +456,6 @@ class Choreographer {
if (!isNormalizationError) continue;
final match = igc.igcTextData!.matches[i];
choreoRecord.addRecord(
_textController.text,
match: match.copyWith..status = PangeaMatchStatus.automatic,
);
igc.igcTextData!.acceptReplacement(
i,
match.match.choices!.indexWhere(
@ -468,6 +463,19 @@ class Choreographer {
),
);
final newMatch = match.copyWith;
newMatch.status = PangeaMatchStatus.automatic;
newMatch.match.length = match.match.choices!
.firstWhere((c) => c.isBestCorrection)
.value
.characters
.length;
choreoRecord.addRecord(
_textController.text,
match: newMatch,
);
_textController.setSystemText(
igc.igcTextData!.originalInput,
EditType.igc,

View file

@ -65,7 +65,17 @@ class ErrorService {
return Duration(seconds: coolDownSeconds);
}
final List<String> _errorCache = [];
setError(ChoreoError? error, {Duration? duration}) {
if (_errorCache.contains(error?.raw.toString())) {
return;
}
if (error != null) {
_errorCache.add(error.raw.toString());
}
_error = error;
Future.delayed(duration ?? defaultCooldown, () {
clear();

View file

@ -293,6 +293,9 @@ class IgcController {
igcTextData = null;
spanDataController.clearCache();
spanDataController.dispose();
MatrixState.pAnyState.closeAllOverlays(
filter: RegExp(r'span_card_overlay_\d+'),
);
}
dispose() {

View file

@ -292,12 +292,45 @@ class IGCTextData {
// create a pointer to the current index in the original input
// and iterate until the pointer has reached the end of the input
int currentIndex = 0;
int loops = 0;
final List<PangeaMatch> addedMatches = [];
while (currentIndex < originalInput.characters.length) {
if (loops > 100) {
ErrorHandler.logError(
e: "In constructTokenSpan, infinite loop detected",
data: {
"currentIndex": currentIndex,
"matches": textSpanMatches.map((m) => m.toJson()).toList(),
},
);
throw "In constructTokenSpan, infinite loop detected";
}
// check if the pointer is at a match, and if so, get the index of the match
final int matchIndex = matchRanges.indexWhere(
(range) => currentIndex >= range[0] && currentIndex < range[1],
);
final bool inMatch = matchIndex != -1;
final bool inMatch = matchIndex != -1 &&
!addedMatches.contains(
textSpanMatches[matchIndex],
);
if (matchIndex != -1 &&
addedMatches.contains(
textSpanMatches[matchIndex],
)) {
ErrorHandler.logError(
e: "In constructTokenSpan, currentIndex is in match that has already been added",
data: {
"currentIndex": currentIndex,
"matchIndex": matchIndex,
"matches": textSpanMatches.map((m) => m.toJson()).toList(),
},
);
throw "In constructTokenSpan, currentIndex is in match that has already been added";
}
final prevIndex = currentIndex;
if (inMatch) {
// if the pointer is in a match, then add that match to items
@ -312,13 +345,7 @@ class IGCTextData {
final span = originalInput.characters
.getRange(
match.match.offset,
match.match.offset +
(match.match.choices
?.firstWhere((c) => c.isBestCorrection)
.value
.characters
.length ??
match.match.length),
match.match.offset + match.match.length,
)
.toString();
@ -364,12 +391,8 @@ class IGCTextData {
),
);
currentIndex = match.match.offset +
(match.match.choices
?.firstWhere((c) => c.isBestCorrection)
.value
.length ??
match.match.length);
addedMatches.add(match);
currentIndex = match.match.offset + match.match.length;
} else {
items.add(
getSpanItem(
@ -400,6 +423,20 @@ class IGCTextData {
);
currentIndex = nextIndex;
}
if (prevIndex >= currentIndex) {
ErrorHandler.logError(
e: "In constructTokenSpan, currentIndex is less than prevIndex",
data: {
"currentIndex": currentIndex,
"prevIndex": prevIndex,
"matches": textSpanMatches.map((m) => m.toJson()).toList(),
},
);
throw "In constructTokenSpan, currentIndex is less than prevIndex";
}
loops++;
}
return items;

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart';
import 'package:fluffychat/pangea/choreographer/models/igc_text_data_model.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart';
@ -174,18 +175,29 @@ class PangeaTextController extends TextEditingController {
final choreoSteps = choreographer.choreoRecord.choreoSteps;
List<InlineSpan> inlineSpans = [];
try {
inlineSpans = choreographer.igc.igcTextData!.constructTokenSpan(
choreoSteps: choreoSteps.isNotEmpty &&
choreoSteps.last.acceptedOrIgnoredMatch?.status ==
PangeaMatchStatus.automatic
? choreoSteps
: [],
defaultStyle: style,
onUndo: choreographer.onUndoReplacement,
);
} catch (e) {
choreographer.errorService.setError(
ChoreoError(type: ChoreoErrorType.unknown, raw: e),
);
inlineSpans = [TextSpan(text: text, style: style)];
choreographer.igc.clear();
}
return TextSpan(
style: style,
children: [
...choreographer.igc.igcTextData!.constructTokenSpan(
choreoSteps: choreoSteps.isNotEmpty &&
choreoSteps.last.acceptedOrIgnoredMatch?.status ==
PangeaMatchStatus.automatic
? choreoSteps
: [],
defaultStyle: style,
onUndo: choreographer.onUndoReplacement,
),
...inlineSpans,
TextSpan(text: parts[1], style: style),
],
);

View file

@ -1,14 +1,14 @@
part of "pangea_room_extension.dart";
extension SpaceRoomExtension on Room {
String classCode(BuildContext context) {
String? classCode(BuildContext context) {
if (!isSpace) {
for (final Room potentialClassRoom in pangeaSpaceParents) {
if (potentialClassRoom.isSpace) {
return SpaceRoomExtension(potentialClassRoom).classCode(context);
}
}
return L10n.of(context).notInClass;
return null;
}
final roomJoinRules = getState(EventTypes.RoomJoinRules, "");
if (roomJoinRules != null) {
@ -17,7 +17,7 @@ extension SpaceRoomExtension on Room {
return accessCode;
}
}
return L10n.of(context).noClassCode;
return null;
}
void checkClass() {

View file

@ -15,25 +15,6 @@ enum ActivityTypeEnum {
}
extension ActivityTypeExtension on ActivityTypeEnum {
String get string {
switch (this) {
case ActivityTypeEnum.wordMeaning:
return 'word_meaning';
case ActivityTypeEnum.wordFocusListening:
return 'word_focus_listening';
case ActivityTypeEnum.hiddenWordListening:
return 'hidden_word_listening';
case ActivityTypeEnum.lemmaId:
return 'lemma_id';
case ActivityTypeEnum.emoji:
return 'emoji';
case ActivityTypeEnum.morphId:
return 'morph_id';
case ActivityTypeEnum.messageMeaning:
return 'message_meaning'; // TODO: Add to L10n
}
}
bool get hiddenType {
switch (this) {
case ActivityTypeEnum.wordMeaning:

View file

@ -83,7 +83,7 @@ class MessageActivityRequest {
'message_tokens': messageTokens.map((e) => e.toJson()).toList(),
'activity_quality_feedback': activityQualityFeedback?.toJson(),
'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
'target_type': targetType.string,
'target_type': targetType.name,
'target_morph_feature': targetMorphFeature,
};
}

View file

@ -326,7 +326,7 @@ class PracticeActivityModel {
Map<String, dynamic> toJson() {
return {
'lang_code': langCode,
'activity_type': activityType.string,
'activity_type': activityType.name,
'content': multipleChoiceContent?.toJson(),
'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
'match_content': matchContent?.toJson(),

View file

@ -48,7 +48,7 @@ class PracticeSelectionRepo {
}
static void clean() {
final Iterable<String> keys = _storage.getKeys();
final keys = _storage.getKeys();
if (keys.length > 300) {
final entries = keys
.map((key) => _parsePracticeSelection(key))

View file

@ -2,6 +2,8 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -60,13 +62,22 @@ class PracticeTarget {
userL2.hashCode;
static PracticeTarget fromJson(Map<String, dynamic> json) {
final type = ActivityTypeEnum.values.firstWhereOrNull(
(v) => json['activityType'] == v.name,
);
if (type == null) {
throw Exception(
"ActivityTypeEnum ${json['activityType']} not found in enum",
);
}
return PracticeTarget(
tokens:
(json['tokens'] as List).map((e) => PangeaToken.fromJson(e)).toList(),
activityType: ActivityTypeEnum.values[json['activityType']],
activityType: type,
morphFeature: json['morphFeature'] == null
? null
: MorphFeaturesEnum.values[json['morphFeature']],
: MorphFeaturesEnumExtension.fromString(json['morphFeature']),
userL2: json['userL2'],
);
}
@ -83,7 +94,7 @@ class PracticeTarget {
//unique condensed deterministic key for local storage
String get storageKey {
return tokens.map((e) => e.text.content).join() +
activityType.string +
activityType.name +
(morphFeature?.name ?? "");
}

View file

@ -6,7 +6,7 @@ description: Learn a language while texting your friends.
# Pangea#
publish_to: none
# On version bump also increase the build number for F-Droid
version: 4.1.10+1
version: 4.1.10+2
environment:
sdk: ">=3.0.0 <4.0.0"