diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index eaccf93a4..4b7befd46 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4116,5 +4116,6 @@ "error520Desc": "Sorry, we could not understand your message...", "wordsUsed": "Words Used", "errorTypes": "Error Types", - "level": "Level" + "level": "Level", + "canceledSend": "Canceled send" } \ No newline at end of file diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index 1de8c8f15..ac4efb668 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -23,6 +23,8 @@ class GetAnalyticsController { String? get l2Code => _pangeaController.languageController.userL2?.langCode; + Client get client => _pangeaController.matrixState.client; + // A local cache of eventIds and construct uses for messages sent since the last update Map> get messagesSinceUpdate { try { @@ -60,17 +62,17 @@ class GetAnalyticsController { } } - /// Get a list of all the construct analytics events - /// for the logged in user in their current L2 - Future?> getConstructs({ + /// Get a list of all constructs used by the logged in user in their current L2 + Future> getConstructs({ bool forceUpdate = false, ConstructTypeEnum? constructType, }) async { debugPrint("getting constructs"); - await _pangeaController.matrixState.client.roomsLoading; + await client.roomsLoading; + // first, try to get a cached list of all uses, if it exists and is valid final DateTime? lastUpdated = await myAnalyticsLastUpdated(); - final List? local = getConstructsLocal( + final List? local = getConstructsLocal( constructType: constructType, lastUpdated: lastUpdated, ); @@ -80,35 +82,46 @@ class GetAnalyticsController { } debugPrint("fetching new constructs"); - final unfilteredConstructs = await allMyConstructs(); - final filteredConstructs = await filterConstructs( - unfilteredConstructs: unfilteredConstructs, + // if there is no cached data (or if force updating), + // get all the construct events for the user from analytics room + // and convert their content into a list of construct uses + final List constructEvents = + await allMyConstructs(); + + final List unfilteredUses = []; + for (final event in constructEvents) { + unfilteredUses.addAll(event.content.uses); + } + + // filter out any constructs that are not relevant to the user + final List filteredUses = await filterConstructs( + unfilteredConstructs: unfilteredUses, ); + // if there isn't already a valid, local cache, cache the filtered uses if (local == null) { cacheConstructs( constructType: constructType, - events: filteredConstructs, + uses: filteredUses, ); } - return filteredConstructs; + return filteredUses; } /// Get the last time the user updated their analytics for their current l2 Future myAnalyticsLastUpdated() async { if (l2Code == null) return null; - final Room? analyticsRoom = - _pangeaController.matrixState.client.analyticsRoomLocal(l2Code!); + final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); if (analyticsRoom == null) return null; final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( - _pangeaController.matrixState.client.userID!, + client.userID!, ); return lastUpdated; } - /// Get the cached construct analytics events for the current user, if it exists - List? getConstructsLocal({ + /// Get the cached construct uses for the current user, if it exists + List? getConstructsLocal({ DateTime? lastUpdated, ConstructTypeEnum? constructType, }) { @@ -121,7 +134,7 @@ class GetAnalyticsController { _cache.removeAt(index); return null; } - return _cache[index].events; + return _cache[index].uses; } return null; @@ -130,48 +143,34 @@ class GetAnalyticsController { /// Get all the construct analytics events for the logged in user Future> allMyConstructs() async { if (l2Code == null) return []; - final Room? analyticsRoom = - _pangeaController.matrixState.client.analyticsRoomLocal(l2Code!); + final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); if (analyticsRoom == null) return []; - - return await analyticsRoom.getAnalyticsEvents( - userId: _pangeaController.matrixState.client.userID!, - ) ?? - []; + return await analyticsRoom.getAnalyticsEvents(userId: client.userID!) ?? []; } /// Filter out constructs that are not relevant to the user, specifically those from /// rooms in which the user is a teacher and those that are interative translation span constructs - Future> filterConstructs({ - required List unfilteredConstructs, + Future> filterConstructs({ + required List unfilteredConstructs, }) async { - final List adminSpaceRooms = - await _pangeaController.matrixState.client.teacherRoomIds; - for (final construct in unfilteredConstructs) { - construct.content.uses.removeWhere( - (use) { - if (adminSpaceRooms.contains(use.chatId)) { - return true; - } - return use.lemma == "Try interactive translation" || - use.lemma == "itStart" || - use.lemma == MatchRuleIds.interactiveTranslation; - }, - ); - } - unfilteredConstructs.removeWhere((e) => e.content.uses.isEmpty); - return unfilteredConstructs; + final List adminSpaceRooms = await client.teacherRoomIds; + return unfilteredConstructs.where((use) { + if (adminSpaceRooms.contains(use.chatId)) return false; + return use.lemma != "Try interactive translation" && + use.lemma != "itStart" || + use.lemma != MatchRuleIds.interactiveTranslation; + }).toList(); } - /// Cache the construct analytics events for the current user + /// Cache the construct uses for the current user void cacheConstructs({ - required List events, + required List uses, ConstructTypeEnum? constructType, }) { if (l2Code == null) return; final entry = AnalyticsCacheEntry( type: constructType, - events: List.from(events), + uses: List.from(uses), langCode: l2Code!, ); _cache.add(entry); @@ -181,13 +180,13 @@ class GetAnalyticsController { class AnalyticsCacheEntry { final String langCode; final ConstructTypeEnum? type; - final List events; + final List uses; late final DateTime _createdAt; AnalyticsCacheEntry({ required this.langCode, required this.type, - required this.events, + required this.uses, }) { _createdAt = DateTime.now(); } diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 0a1048c72..591b7a372 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -25,9 +25,13 @@ class MyAnalyticsController extends BaseController { final StreamController analyticsUpdateStream = StreamController.broadcast(); Timer? _updateTimer; + Client get _client => _pangeaController.matrixState.client; + + String? get userL2 => _pangeaController.languageController.activeL2Code(); + /// the max number of messages that will be cached before /// an automatic update is triggered - final int _maxMessagesCached = 10; + final int _maxMessagesCached = 1; /// the number of minutes before an automatic update is triggered final int _minutesBeforeUpdate = 5; @@ -37,7 +41,11 @@ class MyAnalyticsController extends BaseController { MyAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; - _refreshAnalyticsIfOutdated(); + + // Wait for the next sync in the stream to ensure that the pangea controller + // is fully initialized. It will throw an error if it is not. + _pangeaController.matrixState.client.onSync.stream.first + .then((_) => _refreshAnalyticsIfOutdated()); // Listen to a stream that provides the eventIDs // of new messages sent by the logged in user @@ -66,8 +74,6 @@ class MyAnalyticsController extends BaseController { return lastUpdated; } - Client get _client => _pangeaController.matrixState.client; - /// Given the data from a newly sent message, format and cache /// the message's construct data locally and reset the update timer void onMessageSent(Map data) { @@ -185,8 +191,6 @@ class MyAnalyticsController extends BaseController { } } - String? get userL2 => _pangeaController.languageController.activeL2Code(); - /// top level analytics sending function. Gather recent messages and activity records, /// convert them into the correct formats, and send them to the analytics room Future _updateAnalytics() async { diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index a19fb1676..a22aa82e1 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -1,17 +1,24 @@ import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; /// A wrapper around a list of [OneConstructUse]s, used to simplify /// the process of filtering / sorting / displaying the events. /// Takes a construct type and a list of events class ConstructListModel { - ConstructTypeEnum type; - List uses; + final ConstructTypeEnum type; + final List _uses; ConstructListModel({ required this.type, - required this.uses, - }); + uses, + }) : _uses = uses ?? []; + + List? _constructs; + List? _typedConstructs; + + List get uses => + _uses.where((use) => use.constructType == type).toList(); /// All unique lemmas used in the construct events List get lemmas => constructs.map((e) => e.lemma).toSet().toList(); @@ -19,11 +26,10 @@ class ConstructListModel { /// A list of ConstructUses, each of which contains a lemma and /// a list of uses, sorted by the number of uses List get constructs { - final List filtered = - uses.where((use) => use.constructType == type).toList(); - + // the list of uses doesn't change so we don't have to re-calculate this + if (_constructs != null) return _constructs!; final Map> lemmaToUses = {}; - for (final use in filtered) { + for (final use in uses) { if (use.lemma == null) continue; lemmaToUses[use.lemma!] ??= []; lemmaToUses[use.lemma!]!.add(use); @@ -45,6 +51,79 @@ class ConstructListModel { return a.lemma.compareTo(b.lemma); }); + _constructs = constructUses; return constructUses; } + + /// A list of ConstructUseTypeUses, each of which + /// contains a lemma, a use type, and a list of uses + List get typedConstructs { + if (_typedConstructs != null) return _typedConstructs!; + final List typedConstructs = []; + for (final construct in constructs) { + final typeToUses = >{}; + for (final use in construct.uses) { + typeToUses[use.useType] ??= []; + typeToUses[use.useType]!.add(use); + } + for (final typeEntry in typeToUses.entries) { + typedConstructs.add( + ConstructUseTypeUses( + lemma: construct.lemma, + constructType: type, + useType: typeEntry.key, + uses: typeEntry.value, + ), + ); + } + } + return typedConstructs; + } + + /// The total number of points for all uses of this construct type + int get points { + double totalPoints = 0; + // Minimize the amount of points given for repeated uses of the same lemma. + // i.e., if a lemma is used 4 times without assistance, the point value for + // a use without assistance is 3. So the points would be + // 3/1 + 3/2 + 3/3 + 3/4 = 3 + 1.5 + 1 + 0.75 = 5.25 (instead of 12) + for (final typedConstruct in typedConstructs) { + final pointValue = typedConstruct.useType.pointValue; + double calc = 0.0; + for (int k = 1; k <= typedConstruct.uses.length; k++) { + calc += pointValue / k; + } + totalPoints += calc; + } + return totalPoints.round(); + } +} + +/// One lemma and a list of construct uses for that lemma +class ConstructUses { + final List uses; + final ConstructTypeEnum constructType; + final String lemma; + + ConstructUses({ + required this.uses, + required this.constructType, + required this.lemma, + }); +} + +/// One lemma, a use type, and a list of uses +/// for that lemma and use type +class ConstructUseTypeUses { + final ConstructUseTypeEnum useType; + final ConstructTypeEnum constructType; + final String lemma; + final List uses; + + ConstructUseTypeUses({ + required this.useType, + required this.constructType, + required this.lemma, + required this.uses, + }); } diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index 4346ec1f1..0e62419f0 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -71,18 +71,6 @@ class ConstructAnalyticsModel { } } -class ConstructUses { - final List uses; - final ConstructTypeEnum constructType; - final String lemma; - - ConstructUses({ - required this.uses, - required this.constructType, - required this.lemma, - }); -} - class OneConstructUse { String? lemma; ConstructTypeEnum? constructType; @@ -148,6 +136,8 @@ class OneConstructUse { if (room == null || metadata.eventId == null) return null; return room.getEventById(metadata.eventId!); } + + int get pointValue => useType.pointValue; } class ConstructUseMetaData { diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index 9d032293d..cc79983ba 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -7,7 +7,7 @@ import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/time_span.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart'; -import 'package:fluffychat/pangea/models/analytics/constructs_event.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; @@ -113,7 +113,14 @@ class ConstructListViewState extends State { forceUpdate: true, ) .whenComplete(() => setState(() => fetchingConstructs = false)) - .then((value) => setState(() => _constructs = value)); + .then( + (value) => setState( + () => constructs = ConstructListModel( + type: constructType, + uses: value, + ), + ), + ); refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) { // postframe callback to let widget rebuild with the new selected parameter @@ -126,7 +133,10 @@ class ConstructListViewState extends State { ) .then( (value) => setState(() { - _constructs = value; + ConstructListModel( + type: constructType, + uses: value, + ); }), ); }); @@ -144,12 +154,6 @@ class ConstructListViewState extends State { setState(() {}); } - int get lemmaIndex => - constructs?.indexWhere( - (element) => element.lemma == currentLemma, - ) ?? - -1; - Future getMessageEvent( OneConstructUse use, ) async { @@ -187,14 +191,19 @@ class ConstructListViewState extends State { Future fetchUses() async { if (fetchingUses) return; - if (currentConstruct == null) { + if (currentLemma == null) { setState(() => _msgEvents.clear()); return; } setState(() => fetchingUses = true); try { - final List uses = currentConstruct!.uses; + final List uses = constructs?.constructs + .firstWhereOrNull( + (element) => element.lemma == currentLemma, + ) + ?.uses ?? + []; _msgEvents.clear(); for (final OneConstructUse use in uses) { @@ -213,54 +222,12 @@ class ConstructListViewState extends State { ErrorHandler.logError( e: err, s: s, - m: "Failed to fetch uses for current construct ${currentConstruct?.lemma}", + m: "Failed to fetch uses for current construct $currentLemma", ); } } - List? _constructs; - - List? get constructs { - if (_constructs == null) { - return null; - } - - final List filtered = List.from(_constructs!) - .map((event) => event.content.uses) - .expand((uses) => uses) - .cast() - .where((use) => use.constructType == constructType) - .toList(); - - final Map> lemmaToUses = {}; - for (final use in filtered) { - if (use.lemma == null) continue; - lemmaToUses[use.lemma!] ??= []; - lemmaToUses[use.lemma!]!.add(use); - } - - final constructUses = lemmaToUses.entries - .map( - (entry) => ConstructUses( - lemma: entry.key, - uses: entry.value, - constructType: constructType, - ), - ) - .toList(); - - constructUses.sort((a, b) { - final comp = b.uses.length.compareTo(a.uses.length); - if (comp != 0) return comp; - return a.lemma.compareTo(b.lemma); - }); - - return constructUses; - } - - ConstructUses? get currentConstruct => constructs?.firstWhereOrNull( - (element) => element.lemma == currentLemma, - ); + ConstructListModel? constructs; // given the current lemma and list of message events, return a list of // MessageEventMatch objects, which contain one PangeaMessageEvent to one PangeaMatch @@ -309,7 +276,7 @@ class ConstructListViewState extends State { ); } - if (constructs?.isEmpty ?? true) { + if (constructs?.constructs.isEmpty ?? true) { return Expanded( child: Center(child: Text(L10n.of(context)!.noDataFound)), ); @@ -317,17 +284,17 @@ class ConstructListViewState extends State { return Expanded( child: ListView.builder( - itemCount: constructs!.length, + itemCount: constructs!.constructs.length, itemBuilder: (context, index) { return ListTile( title: Text( - constructs![index].lemma, + constructs!.constructs[index].lemma, ), subtitle: Text( - '${L10n.of(context)!.total} ${constructs![index].uses.length}', + '${L10n.of(context)!.total} ${constructs!.constructs[index].uses.length}', ), onTap: () async { - final String lemma = constructs![index].lemma; + final String lemma = constructs!.constructs[index].lemma; setCurrentLemma(lemma); fetchUses().then((_) => showConstructMessagesDialog()); }, @@ -347,17 +314,17 @@ class ConstructMessagesDialog extends StatelessWidget { @override Widget build(BuildContext context) { - if (controller.currentLemma == null || - controller.constructs == null || - controller.lemmaIndex < 0 || - controller.lemmaIndex >= controller.constructs!.length) { + if (controller.currentLemma == null || controller.constructs == null) { return const AlertDialog(content: CircularProgressIndicator.adaptive()); } final msgEventMatches = controller.getMessageEventMatches(); - final noData = controller.constructs![controller.lemmaIndex].uses.length > - controller._msgEvents.length; + final currentConstruct = controller.constructs!.constructs.firstWhereOrNull( + (construct) => construct.lemma == controller.currentLemma, + ); + final noData = currentConstruct == null || + currentConstruct.uses.length > controller._msgEvents.length; return AlertDialog( title: Center(child: Text(controller.currentLemma!)), diff --git a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart index 5a30b9d0d..a127a6831 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart @@ -8,7 +8,6 @@ import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart'; -import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; @@ -62,13 +61,14 @@ class LearningProgressIndicatorsState /// Update the analytics data shown in the UI. This comes from a /// combination of stored events and locally cached data. Future updateAnalyticsData() async { - final constructEvents = await _pangeaController.analytics.getConstructs(); + final List storedUses = + await _pangeaController.analytics.getConstructs(); final List localUses = []; for (final uses in _pangeaController.analytics.messagesSinceUpdate.values) { localUses.addAll(uses); } - if (constructEvents == null || constructEvents.isEmpty) { + if (storedUses.isEmpty) { words = ConstructListModel( type: ConstructTypeEnum.vocab, uses: localUses, @@ -80,10 +80,8 @@ class LearningProgressIndicatorsState return; } - final List storedConstruct = - constructEvents.expand((e) => e.content.uses).toList(); final List allConstructs = [ - ...storedConstruct, + ...storedUses, ...localUses, ]; @@ -98,6 +96,7 @@ class LearningProgressIndicatorsState setState(() {}); } + /// Get the number of points for a given progress indicator int? getProgressPoints(ProgressIndicatorEnum indicator) { switch (indicator) { case ProgressIndicatorEnum.wordsUsed: @@ -109,15 +108,31 @@ class LearningProgressIndicatorsState } } + /// Get the total number of xp points, based on the point values of use types int get xpPoints { - final points = [ - words?.lemmas.length ?? 0, - errors?.lemmas.length ?? 0, - ]; - return points.reduce((a, b) => a + b); + return (words?.points ?? 0) + (errors?.points ?? 0); } - int get level => xpPoints ~/ 100; + /// Get the current level based on the number of xp points + int get level => xpPoints ~/ 500; + + double get levelBarWidth => FluffyThemes.columnWidth - (36 * 2) - 25; + double get pointsBarWidth { + final percent = (xpPoints % 500) / 500; + return levelBarWidth * percent; + } + + Color levelColor(int level) { + final colors = [ + const Color.fromARGB(255, 33, 97, 140), // Dark blue + const Color.fromARGB(255, 186, 104, 200), // Soft purple + const Color.fromARGB(255, 123, 31, 162), // Deep purple + const Color.fromARGB(255, 0, 150, 136), // Teal + const Color.fromARGB(255, 247, 143, 143), // Light pink + const Color.fromARGB(255, 220, 20, 60), // Crimson red + ]; + return colors[level % colors.length]; + } @override Widget build(BuildContext context) { @@ -130,7 +145,7 @@ class LearningProgressIndicatorsState mainAxisSize: MainAxisSize.min, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, children: [ FutureBuilder( future: @@ -143,25 +158,25 @@ class LearningProgressIndicatorsState return Avatar( name: snapshot.data?.displayName ?? mxid.localpart ?? mxid, mxContent: snapshot.data?.avatarUrl, + size: 40, ); }, ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: ProgressIndicatorEnum.values - .where( - (indicator) => indicator != ProgressIndicatorEnum.level, - ) - .map( - (indicator) => ProgressIndicatorBadge( - points: getProgressPoints(indicator), - onTap: () {}, - progressIndicator: indicator, - ), - ) - .toList(), - ), + const SizedBox(width: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: ProgressIndicatorEnum.values + .where( + (indicator) => indicator != ProgressIndicatorEnum.level, + ) + .map( + (indicator) => ProgressIndicatorBadge( + points: getProgressPoints(indicator), + onTap: () {}, + progressIndicator: indicator, + ), + ) + .toList(), ), ], ), @@ -173,31 +188,41 @@ class LearningProgressIndicatorsState children: [ Positioned( right: 0, + left: 10, child: Row( children: [ SizedBox( - width: FluffyThemes.columnWidth - (36 * 2) - 25, + width: levelBarWidth, child: Expanded( child: Stack( alignment: Alignment.centerLeft, children: [ Container( - height: 15, + height: 20, decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.5), + width: 2, ), - color: - Theme.of(context).colorScheme.onPrimary, + borderRadius: const BorderRadius.only( + topRight: + Radius.circular(AppConfig.borderRadius), + bottomRight: + Radius.circular(AppConfig.borderRadius), + ), + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.2), ), ), AnimatedContainer( duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - height: 15, - width: - (FluffyThemes.columnWidth - (36 * 2) - 25) * - ((xpPoints % 100) / 100), + height: 16, + width: pointsBarWidth, decoration: BoxDecoration( borderRadius: BorderRadius.circular( AppConfig.borderRadius, @@ -214,12 +239,18 @@ class LearningProgressIndicatorsState ), Positioned( left: 0, - child: CircleAvatar( - backgroundColor: "$level $xpPoints".lightColorAvatar, - radius: 16, - child: Text( - "$level", - style: const TextStyle(color: Colors.white), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: levelColor(level), + borderRadius: BorderRadius.circular(32), + ), + child: Center( + child: Text( + "$level", + style: const TextStyle(color: Colors.white), + ), ), ), ), diff --git a/lib/utils/matrix_sdk_extensions/matrix_locals.dart b/lib/utils/matrix_sdk_extensions/matrix_locals.dart index b4536b6db..333993442 100644 --- a/lib/utils/matrix_sdk_extensions/matrix_locals.dart +++ b/lib/utils/matrix_sdk_extensions/matrix_locals.dart @@ -346,8 +346,8 @@ class MatrixLocals extends MatrixLocalizations { l10n.startedKeyVerification(senderName); @override - String invitedBy(String senderName) { - // TODO: implement invitedBy - throw UnimplementedError(); - } + String invitedBy(String senderName) => l10n.youInvitedBy(senderName); + + @override + String get cancelledSend => l10n.canceledSend; }