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:
parent
a24ee27f9b
commit
e96a16b297
17 changed files with 236 additions and 146 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -293,6 +293,9 @@ class IgcController {
|
|||
igcTextData = null;
|
||||
spanDataController.clearCache();
|
||||
spanDataController.dispose();
|
||||
MatrixState.pAnyState.closeAllOverlays(
|
||||
filter: RegExp(r'span_card_overlay_\d+'),
|
||||
);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 ?? "");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue