inital work for having a 2-stage progress bar and saving draft analytics locally

This commit is contained in:
ggurdin 2024-08-14 09:07:10 -04:00
parent 474198c7a1
commit 75234e6a6f
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
22 changed files with 859 additions and 557 deletions

View file

@ -12,6 +12,7 @@ import 'package:fluffychat/pages/chat/reply_display.dart';
import 'package:fluffychat/pangea/choreographer/widgets/it_bar.dart';
import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/widgets/animations/gain_points.dart';
import 'package:fluffychat/pangea/widgets/chat/chat_floating_action_button.dart';
import 'package:fluffychat/utils/account_config.dart';
import 'package:fluffychat/widgets/chat_settings_popup_menu.dart';
@ -466,8 +467,15 @@ class ChatView extends StatelessWidget {
StartIGCButton(
controller: controller,
),
ChatFloatingActionButton(
controller: controller,
Row(
children: [
const PointsGainedAnimation(
color: Colors.blue,
),
ChatFloatingActionButton(
controller: controller,
),
],
),
],
),

View file

@ -931,6 +931,8 @@ class ChatListController extends State<ChatList>
}
// #Pangea
MatrixState.pangeaController.myAnalytics.initialize();
MatrixState.pangeaController.analytics.initialize();
await _initPangeaControllers(client);
// Pangea#
if (!mounted) return;

View file

@ -411,6 +411,7 @@ class Choreographer {
choreoRecord = ChoreoRecord.newRecord;
itController.clear();
igc.clear();
pangeaController.myAnalytics.clearDraftConstructUses(roomId);
// errorService.clear();
_resetDebounceTimer();
}

View file

@ -318,6 +318,26 @@ class ITChoices extends StatelessWidget {
);
}
void selectContinuance(int index, BuildContext context) {
final Continuance continuance =
controller.currentITStep!.continuances[index];
if (continuance.level == 1 || continuance.wasClicked) {
Future.delayed(
const Duration(milliseconds: 500),
() => controller.selectTranslation(index),
);
} else {
showCard(
context,
index,
continuance.level == 2 ? ChoreoConstants.yellow : ChoreoConstants.red,
continuance.feedbackText(context),
);
}
controller.currentITStep!.continuances[index].wasClicked = true;
controller.choreographer.setState();
}
@override
Widget build(BuildContext context) {
try {
@ -342,31 +362,8 @@ class ITChoices extends StatelessWidget {
return Choice(text: "error", color: Colors.red);
}
}).toList(),
onPressed: (int index) {
final Continuance continuance =
controller.currentITStep!.continuances[index];
debugPrint("is gold? ${continuance.gold}");
if (continuance.level == 1 || continuance.wasClicked) {
Future.delayed(
const Duration(milliseconds: 500),
() => controller.selectTranslation(index),
);
} else {
showCard(
context,
index,
continuance.level == 2
? ChoreoConstants.yellow
: ChoreoConstants.red,
continuance.feedbackText(context),
);
}
controller.currentITStep!.continuances[index].wasClicked = true;
controller.choreographer.setState();
},
onLongPress: (int index) {
showCard(context, index);
},
onPressed: (int index) => selectContinuance(index, context),
onLongPress: (int index) => showCard(context, index),
uniqueKeyForLayerLink: (int index) => "itChoices$index",
selectedChoiceIndex: null,
);

View file

@ -0,0 +1,3 @@
class AnalyticsConstants {
static const int xpPerLevel = 2000;
}

View file

@ -1,33 +1,122 @@
import 'dart:async';
import 'package:fluffychat/pangea/constants/analytics_constants.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/match_rule_ids.dart';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController {
late PangeaController _pangeaController;
final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdateType>? _analyticsUpdateSubscription;
CachedStreamController<List<OneConstructUse>> analyticsStream =
CachedStreamController<List<OneConstructUse>>();
/// The previous XP points of the user, before the last update.
/// Used for animating analytics updates.
int? prevXP;
GetAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
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
int get currentXP => calcXP(allConstructUses);
int get localXP => calcXP(locallyCachedConstructs);
int get serverXP => currentXP - localXP;
/// Get the current level based on the number of xp points
int get level => currentXP ~/ AnalyticsConstants.xpPerLevel;
void initialize() {
_analyticsUpdateSubscription ??= _pangeaController
.myAnalytics.analyticsUpdateStream.stream
.listen(onAnalyticsUpdate);
_pangeaController.myAnalytics.lastUpdatedCompleter.future.then((_) {
getConstructs().then((_) => updateAnalyticsStream());
});
}
/// Clear all cached analytics data.
void dispose() {
_analyticsUpdateSubscription?.cancel();
_analyticsUpdateSubscription = null;
_cache.clear();
}
Future<void> onAnalyticsUpdate(AnalyticsUpdateType type) async {
if (type == AnalyticsUpdateType.server) {
await getConstructs(forceUpdate: true);
}
updateAnalyticsStream();
}
void updateAnalyticsStream() {
// if there are no construct uses, or if the last update in this
// stream has the same length as this update, don't update the stream
if (allConstructUses.isEmpty ||
allConstructUses.length == analyticsStream.value?.length) {
return;
}
// set the previous XP to the currentXP
if (analyticsStream.value != null) {
prevXP = calcXP(analyticsStream.value!);
}
// finally, add to the stream
analyticsStream.add(allConstructUses);
}
/// Calculates the user's xpPoints for their current L2,
/// based on matrix analytics event and locally cached data.
/// Has to be async because cached matrix events may be out of date,
/// and updating those is async.
int calcXP(List<OneConstructUse> constructs) {
final words = ConstructListModel(
uses: constructs,
type: ConstructTypeEnum.vocab,
);
final errors = ConstructListModel(
uses: constructs,
type: ConstructTypeEnum.grammar,
);
return words.points + errors.points;
}
List<OneConstructUse> get allConstructUses {
final List<OneConstructUse> storedUses = getConstructsLocal() ?? [];
final List<OneConstructUse> localUses = locallyCachedConstructs;
final List<OneConstructUse> allConstructs = [
...storedUses,
...localUses,
];
return allConstructs;
}
/// A local cache of eventIds and construct uses for messages sent since the last update.
/// It's a map of eventIDs to a list of OneConstructUses. Not just a list of OneConstructUses
/// because, with practice activity constructs, we might need to add to the list for a given
/// eventID.
Map<String, List<OneConstructUse>> get messagesSinceUpdate {
try {
final dynamic locallySaved = _pangeaController.pStoreService.read(
@ -61,29 +150,34 @@ class GetAnalyticsController {
}
}
/// A flat list of all locally cached construct uses
List<OneConstructUse> get locallyCachedConstructs =>
messagesSinceUpdate.values.expand((e) => e).toList();
/// A flat list of all locally cached construct uses that are not drafts
List<OneConstructUse> get locallyCachedSentConstructs =>
messagesSinceUpdate.entries
.where((entry) => !entry.key.startsWith('draft'))
.expand((e) => e.value)
.toList();
/// Get a list of all constructs used by the logged in user in their current L2
Future<List<OneConstructUse>> getConstructs({
bool forceUpdate = false,
ConstructTypeEnum? constructType,
}) async {
debugPrint("getting constructs");
// if the user isn't logged in, return an empty list
if (client.userID == null) return [];
await client.roomsLoading;
// don't try to get constructs until last updated time has been loaded
await _pangeaController.myAnalytics.lastUpdatedCompleter.future;
// if forcing a refreshing, clear the cache
if (forceUpdate) clearCache();
// get the last time the user updated their analytics for their current l2
// then try to get local cache of construct uses. lastUpdate time is used to
// determine if cached data is still valid.
final DateTime? lastUpdated = _pangeaController.myAnalytics.lastUpdated ??
await myAnalyticsLastUpdated();
if (forceUpdate) _cache.clear();
final List<OneConstructUse>? local = getConstructsLocal(
constructType: constructType,
lastUpdated: lastUpdated,
);
if (local != null) {
@ -160,7 +254,6 @@ class GetAnalyticsController {
/// Get the cached construct uses for the current user, if it exists
List<OneConstructUse>? getConstructsLocal({
DateTime? lastUpdated,
ConstructTypeEnum? constructType,
}) {
final index = _cache.indexWhere(
@ -168,6 +261,7 @@ class GetAnalyticsController {
);
if (index > -1) {
final DateTime? lastUpdated = _pangeaController.myAnalytics.lastUpdated;
if (_cache[index].needsUpdate(lastUpdated)) {
_cache.removeAt(index);
return null;
@ -191,11 +285,6 @@ class GetAnalyticsController {
);
_cache.add(entry);
}
/// Clear all cached analytics data.
void clearCache() {
_cache.clear();
}
}
class AnalyticsCacheEntry {

View file

@ -4,24 +4,32 @@ import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
enum AnalyticsUpdateType { server, local }
/// handles the processing of analytics for
/// 1) messages sent by the user and
/// 2) constructs used by the user, both in sending messages and doing practice activities
class MyAnalyticsController extends BaseController {
late PangeaController _pangeaController;
final StreamController analyticsUpdateStream = StreamController.broadcast();
CachedStreamController<AnalyticsUpdateType> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdateType>();
StreamSubscription? _messageSendSubscription;
Timer? _updateTimer;
Client get _client => _pangeaController.matrixState.client;
@ -37,7 +45,7 @@ class MyAnalyticsController extends BaseController {
/// the max number of messages that will be cached before
/// an automatic update is triggered
final int _maxMessagesCached = 1;
final int _maxMessagesCached = 10;
/// the number of minutes before an automatic update is triggered
final int _minutesBeforeUpdate = 5;
@ -47,22 +55,28 @@ class MyAnalyticsController extends BaseController {
MyAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
// 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.
if (_pangeaController.matrixState.client.prevBatch == null) {
_pangeaController.matrixState.client.onSync.stream.first.then(
(_) => _refreshAnalyticsIfOutdated(),
);
} else {
_refreshAnalyticsIfOutdated();
}
void initialize() {
// Listen to a stream that provides the eventIDs
// of new messages sent by the logged in user
stateStream.where((data) => data is Map).listen((data) {
onMessageSent(data as Map<String, dynamic>);
});
_messageSendSubscription ??= stateStream
.where((data) => data is Map)
.listen((data) => onMessageSent(data as Map<String, dynamic>));
_refreshAnalyticsIfOutdated();
}
/// Reset analytics last updated time to null.
@override
void dispose() {
_updateTimer?.cancel();
lastUpdated = null;
lastUpdatedCompleter = Completer<DateTime?>();
_messageSendSubscription?.cancel();
_messageSendSubscription = null;
_refreshAnalyticsIfOutdated();
clearMessagesSinceUpdate();
}
/// If analytics haven't been updated in the last day, update them
@ -98,6 +112,7 @@ class MyAnalyticsController extends BaseController {
void onMessageSent(Map<String, dynamic> data) {
// cancel the last timer that was set on message event and
// reset it to fire after _minutesBeforeUpdate minutes
debugPrint("ONE MESSAGE SENT");
_updateTimer?.cancel();
_updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () {
debugPrint("timer fired, updating analytics");
@ -154,27 +169,82 @@ class MyAnalyticsController extends BaseController {
_pangeaController.analytics
.filterConstructs(unfilteredConstructs: constructs)
.then((filtered) => addMessageSinceUpdate(eventID, filtered));
.then((filtered) {
if (filtered.isEmpty) return;
final level = _pangeaController.analytics.level;
addLocalMessage(eventID, filtered).then(
(_) => afterAddLocalMessages(level),
);
});
}
/// Called when the user selects a replacement during IGC
void onReplacementSelected(
List<PangeaToken> tokens,
String roomID,
bool isBestCorrection,
) {
final useType = isBestCorrection
? ConstructUseTypeEnum.corIGC
: ConstructUseTypeEnum.incIGC;
setDraftConstructUses(tokens, roomID, useType);
}
/// Called when the user ignores a match during IGC
void onIgnoreMatch(
List<PangeaToken> tokens,
String roomID,
) {
const useType = ConstructUseTypeEnum.ignIGC;
setDraftConstructUses(tokens, roomID, useType);
}
void setDraftConstructUses(
List<PangeaToken> tokens,
String roomID,
ConstructUseTypeEnum useType,
) {
final metadata = ConstructUseMetaData(
roomId: roomID,
timeStamp: DateTime.now(),
);
final uses = tokens
.map(
(token) => OneConstructUse(
useType: useType,
lemma: token.lemma.text,
form: token.lemma.form,
constructType: ConstructTypeEnum.vocab,
metadata: metadata,
),
)
.toList();
addLocalMessage('draft$roomID', uses);
}
void clearDraftConstructUses(String roomID) {
final currentCache = _pangeaController.analytics.messagesSinceUpdate;
currentCache.remove('draft$roomID');
setMessagesSinceUpdate(currentCache);
}
/// Called when the user selects a continuance during IT
/// TODO implement
void onSelectContinuance() {}
/// Add a list of construct uses for a new message to the local
/// cache of recently sent messages
void addMessageSinceUpdate(
Future<void> addLocalMessage(
String eventID,
List<OneConstructUse> constructs,
) {
) async {
try {
final currentCache = _pangeaController.analytics.messagesSinceUpdate;
constructs.addAll(currentCache[eventID] ?? []);
currentCache[eventID] = constructs;
setMessagesSinceUpdate(currentCache);
// if the cached has reached if max-length, update analytics
if (_pangeaController.analytics.messagesSinceUpdate.length >
_maxMessagesCached) {
debugPrint("reached max messages, updating");
updateAnalytics();
}
await setMessagesSinceUpdate(currentCache);
} catch (e, s) {
ErrorHandler.logError(
e: PangeaWarningError("Failed to add message since update: $e"),
@ -184,23 +254,42 @@ class MyAnalyticsController extends BaseController {
}
}
/// Handles cleanup after adding a new message to the local cache.
/// If the addition brought the total number of messages in the cache
/// to the max, or if the addition triggered a level-up, update the analytics.
/// Otherwise, add a local update to the alert stream.
void afterAddLocalMessages(int prevLevel) {
if (_pangeaController.analytics.messagesSinceUpdate.length >
_maxMessagesCached) {
debugPrint("reached max messages, updating");
updateAnalytics();
return;
}
final int newLevel = _pangeaController.analytics.level;
newLevel > prevLevel
? updateAnalytics()
: analyticsUpdateStream.add(AnalyticsUpdateType.local);
}
/// Clears the local cache of recently sent constructs. Called before updating analytics
void clearMessagesSinceUpdate() {
_pangeaController.pStoreService.delete(PLocalKey.messagesSinceUpdate);
}
/// Save the local cache of recently sent constructs to the local storage
void setMessagesSinceUpdate(Map<String, List<OneConstructUse>> cache) {
Future<void> setMessagesSinceUpdate(
Map<String, List<OneConstructUse>> cache,
) async {
final formattedCache = {};
for (final entry in cache.entries) {
final constructJsons = entry.value.map((e) => e.toJson()).toList();
formattedCache[entry.key] = constructJsons;
}
_pangeaController.pStoreService.save(
await _pangeaController.pStoreService.save(
PLocalKey.messagesSinceUpdate,
formattedCache,
);
analyticsUpdateStream.add(null);
}
/// Prevent concurrent updates to analytics
@ -223,8 +312,9 @@ class MyAnalyticsController extends BaseController {
try {
await _updateAnalytics();
clearMessagesSinceUpdate();
lastUpdated = DateTime.now();
analyticsUpdateStream.add(null);
analyticsUpdateStream.add(AnalyticsUpdateType.server);
} catch (err, s) {
ErrorHandler.logError(
e: err,
@ -241,7 +331,10 @@ class MyAnalyticsController extends BaseController {
/// The analytics room is determined based on the user's current target language.
Future<void> _updateAnalytics() async {
// if there's no cached construct data, there's nothing to send
if (_pangeaController.analytics.messagesSinceUpdate.isEmpty) return;
final cachedConstructs = _pangeaController.analytics.messagesSinceUpdate;
final bool onlyDraft = cachedConstructs.length == 1 &&
cachedConstructs.keys.single.startsWith('draft');
if (cachedConstructs.isEmpty || onlyDraft) return;
// if missing important info, don't send analytics. Could happen if user just signed up.
if (userL2 == null || _client.userID == null) return;
@ -251,17 +344,7 @@ class MyAnalyticsController extends BaseController {
// and send cached analytics data to the room
await analyticsRoom?.sendConstructsEvent(
_pangeaController.analytics.messagesSinceUpdate.values
.expand((e) => e)
.toList(),
_pangeaController.analytics.locallyCachedSentConstructs,
);
}
/// Reset analytics last updated time to null.
void clearCache() {
_updateTimer?.cancel();
lastUpdated = null;
lastUpdatedCompleter = Completer<DateTime?>();
_refreshAnalyticsIfOutdated();
}
}

View file

@ -141,6 +141,19 @@ class PangeaController {
/// check user information if not found then redirect to Date of birth page
_handleLoginStateChange(LoginState state) {
switch (state) {
case LoginState.loggedOut:
case LoginState.softLoggedOut:
// Reset cached analytics data
MatrixState.pangeaController.myAnalytics.dispose();
MatrixState.pangeaController.analytics.dispose();
break;
case LoginState.loggedIn:
// Initialize analytics data
MatrixState.pangeaController.myAnalytics.initialize();
MatrixState.pangeaController.analytics.initialize();
break;
}
if (state != LoginState.loggedIn) {
_logOutfromPangea();
}

View file

@ -82,20 +82,27 @@ class ConstructListModel {
/// The total number of points for all uses of this construct type
int get points {
double totalPoints = 0;
// double totalPoints = 0;
return typedConstructs.fold<int>(
0,
(total, typedConstruct) =>
total +
typedConstruct.useType.pointValue * typedConstruct.uses.length,
);
// Commenting this out for now
// 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();
// 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();
}
}

View file

@ -227,37 +227,6 @@ class IGCTextData {
decorationThickness: 5,
);
List<MatchToken> getMatchTokens() {
final List<MatchToken> matchTokens = [];
int? endTokenIndex;
PangeaMatch? topMatch;
for (final (i, token) in tokens.indexed) {
if (endTokenIndex != null) {
if (i <= endTokenIndex) {
matchTokens.add(
MatchToken(
token: token,
match: topMatch,
),
);
continue;
}
endTokenIndex = null;
}
topMatch = getTopMatchForToken(token);
if (topMatch != null) {
endTokenIndex = tokens.indexWhere((e) => e.end >= topMatch!.end, i);
}
matchTokens.add(
MatchToken(
token: token,
match: topMatch,
),
);
}
return matchTokens;
}
TextSpan getSpanItem({
required int start,
required int end,
@ -347,11 +316,19 @@ class IGCTextData {
return items;
}
}
class MatchToken {
final PangeaToken token;
final PangeaMatch? match;
List<PangeaToken> matchTokens(int matchIndex) {
if (matchIndex >= matches.length) {
return [];
}
MatchToken({required this.token, this.match});
final PangeaMatch match = matches[matchIndex];
final List<PangeaToken> tokensForMatch = [];
for (final token in tokens) {
if (match.isOffsetInMatchSpan(token.text.offset)) {
tokensForMatch.add(token);
}
}
return tokensForMatch;
}
}

View file

@ -5,6 +5,8 @@
// Call to server for additional/followup info
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:flutter/material.dart';
import '../enum/span_choice_type.dart';
@ -99,14 +101,31 @@ class Context {
}
class SpanChoice {
String value;
SpanChoiceType type;
bool selected;
String? feedback;
DateTime? timestamp;
List<PangeaToken> tokens;
SpanChoice({
required this.value,
required this.type,
this.feedback,
this.selected = false,
this.timestamp,
this.tokens = const [],
});
factory SpanChoice.fromJson(Map<String, dynamic> json) {
final List<PangeaToken> tokensInternal = (json[ModelKey.tokens] != null)
? (json[ModelKey.tokens] as Iterable)
.map<PangeaToken>(
(e) => PangeaToken.fromJson(e as Map<String, dynamic>),
)
.toList()
.cast<PangeaToken>()
: [];
return SpanChoice(
value: json['value'] as String,
type: json['type'] != null
@ -119,21 +138,17 @@ class SpanChoice {
selected: json['selected'] ?? false,
timestamp:
json['timestamp'] != null ? DateTime.parse(json['timestamp']) : null,
tokens: tokensInternal,
);
}
String value;
SpanChoiceType type;
bool selected;
String? feedback;
DateTime? timestamp;
Map<String, dynamic> toJson() => {
'value': value,
'type': type.name,
'selected': selected,
'feedback': feedback,
'timestamp': timestamp?.toIso8601String(),
'tokens': tokens.map((e) => e.toJson()).toList(),
};
String feedbackToDisplay(BuildContext context) {

View file

@ -22,10 +22,6 @@ void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async {
// before wiping out locally cached construct data, save it to the server
await MatrixState.pangeaController.myAnalytics.updateAnalytics();
// Reset cached analytics data
MatrixState.pangeaController.myAnalytics.clearCache();
MatrixState.pangeaController.analytics.clearCache();
await showFutureLoadingDialog(
context: context,
future: () => matrix.client.logout(),

View file

@ -0,0 +1,103 @@
import 'dart:async';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class PointsGainedAnimation extends StatefulWidget {
final Color? color;
const PointsGainedAnimation({super.key, this.color});
@override
PointsGainedAnimationState createState() => PointsGainedAnimationState();
}
class PointsGainedAnimationState extends State<PointsGainedAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _fadeAnimation;
StreamSubscription? _pointsSubscription;
int? get _prevXP => MatrixState.pangeaController.analytics.prevXP;
int? get _currentXP => MatrixState.pangeaController.analytics.currentXP;
int? _addedPoints;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.0),
end: const Offset(0.0, -1.0),
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
),
);
_fadeAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
),
);
_pointsSubscription = MatrixState
.pangeaController.analytics.analyticsStream.stream
.listen(_showPointsGained);
}
@override
void dispose() {
_controller.dispose();
_pointsSubscription?.cancel();
super.dispose();
}
void _showPointsGained(List<OneConstructUse> constructs) {
setState(() => _addedPoints = (_currentXP ?? 0) - (_prevXP ?? 0));
if (_prevXP != _currentXP && !_controller.isAnimating) {
_controller.reset();
_controller.forward();
}
}
bool get animate =>
_currentXP != null &&
_prevXP != null &&
_addedPoints != null &&
_prevXP! != _currentXP!;
@override
Widget build(BuildContext context) {
if (!animate) return const SizedBox();
return SlideTransition(
position: _offsetAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Text(
'+$_addedPoints',
style: BotStyle.text(
context,
big: true,
setColor: widget.color == null,
existingStyle: TextStyle(
color: widget.color,
),
),
),
),
);
}
}

View file

@ -0,0 +1,87 @@
import 'package:fluffychat/config/themes.dart';
import 'package:flutter/material.dart';
class AnimatedLevelBar extends StatefulWidget {
final double height;
final double beginWidth;
final double endWidth;
final BoxDecoration? decoration;
const AnimatedLevelBar({
super.key,
required this.height,
required this.beginWidth,
required this.endWidth,
this.decoration,
});
@override
AnimatedLevelBarState createState() => AnimatedLevelBarState();
}
class AnimatedLevelBarState extends State<AnimatedLevelBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
/// Whether the animation has run for the first time during initState. Don't
/// want the animation to run when the widget mounts, only when points are gained.
bool _init = true;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: FluffyThemes.animationDuration,
);
_controller.forward().then((_) => _init = false);
}
@override
void didUpdateWidget(covariant AnimatedLevelBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.endWidth != widget.endWidth) {
_controller.reset();
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Animation<double> get _animation {
// If this is the first run of the animation, don't animate. This is just the widget mounting,
// not a points gain. This could instead be 'if going from 0 to a non-zero value', but that
// would remove the animation for first points gained. It would remove the need for a flag though.
if (_init) {
return Tween<double>(
begin: widget.endWidth,
end: widget.endWidth,
).animate(_controller);
}
// animate the width of the bar
return Tween<double>(
begin: widget.beginWidth,
end: widget.endWidth,
).animate(_controller);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
height: widget.height,
width: _animation.value,
decoration: widget.decoration,
);
},
);
}
}

View file

@ -0,0 +1,59 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/analytics_constants.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/animated_level_dart.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart';
import 'package:flutter/material.dart';
class LevelBar extends StatefulWidget {
final LevelBarDetails details;
final ProgressBarDetails progressBarDetails;
const LevelBar({
super.key,
required this.details,
required this.progressBarDetails,
});
@override
LevelBarState createState() => LevelBarState();
}
class LevelBarState extends State<LevelBar> {
double prevWidth = 0;
double get width {
const perLevel = AnalyticsConstants.xpPerLevel;
final percent = (widget.details.currentPoints % perLevel) / perLevel;
return widget.progressBarDetails.totalWidth * percent;
}
@override
void didUpdateWidget(covariant LevelBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.details.currentPoints != widget.details.currentPoints) {
setState(() => prevWidth = width);
}
}
@override
Widget build(BuildContext context) {
return AnimatedLevelBar(
height: widget.progressBarDetails.height,
beginWidth: prevWidth,
endWidth: width,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
color: widget.details.fillColor,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 5,
offset: const Offset(5, 0),
),
],
),
);
}
}

View file

@ -0,0 +1,36 @@
import 'package:fluffychat/pangea/widgets/animations/progress_bar/level_bar.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_background.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart';
import 'package:flutter/material.dart';
// Provide an order list of level indicators, each with it's color
// and stream. Also provide an overall width and pointsPerLevel.
class ProgressBar extends StatelessWidget {
final List<LevelBarDetails> levelBars;
final ProgressBarDetails progressBarDetails;
const ProgressBar({
super.key,
required this.levelBars,
required this.progressBarDetails,
});
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.centerLeft,
children: [
ProgressBarBackground(details: progressBarDetails),
for (final levelBar in levelBars)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: LevelBar(
details: levelBar,
progressBarDetails: progressBarDetails,
),
),
],
);
}
}

View file

@ -0,0 +1,30 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart';
import 'package:flutter/material.dart';
class ProgressBarBackground extends StatelessWidget {
final ProgressBarDetails details;
const ProgressBarBackground({
super.key,
required this.details,
});
@override
Widget build(BuildContext context) {
return Container(
height: details.height + 4,
width: details.totalWidth + 4,
decoration: BoxDecoration(
border: Border.all(
color: details.borderColor.withOpacity(0.5),
width: 2,
),
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
color: details.borderColor.withOpacity(0.2),
),
);
}
}

View file

@ -0,0 +1,23 @@
import 'dart:ui';
class LevelBarDetails {
final Color fillColor;
final int currentPoints;
const LevelBarDetails({
required this.fillColor,
required this.currentPoints,
});
}
class ProgressBarDetails {
final double totalWidth;
final Color borderColor;
final double height;
const ProgressBarDetails({
required this.totalWidth,
required this.borderColor,
this.height = 16,
});
}

View file

@ -1,12 +1,15 @@
import 'dart:async';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/constants/analytics_constants.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
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/animations/gain_points.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart';
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -34,7 +37,7 @@ class LearningProgressIndicatorsState
/// A stream subscription to listen for updates to
/// the analytics data, either locally or from events
StreamSubscription? _onAnalyticsUpdate;
StreamSubscription<List<OneConstructUse>>? _analyticsUpdateSubscription;
/// Vocabulary constructs model
ConstructListModel? words;
@ -44,66 +47,39 @@ class LearningProgressIndicatorsState
bool loading = true;
/// The previous number of XP points, used to determine when to animate the level bar
int? previousXP;
int get serverXP => _pangeaController.analytics.serverXP;
int get totalXP => _pangeaController.analytics.currentXP;
int get level => _pangeaController.analytics.level;
@override
void initState() {
super.initState();
updateAnalyticsData().then((_) {
setState(() => loading = false);
});
// listen for changes to analytics data and update the UI
_onAnalyticsUpdate = _pangeaController
.myAnalytics.analyticsUpdateStream.stream
.listen((_) => updateAnalyticsData());
updateAnalyticsData(
_pangeaController.analytics.analyticsStream.value ?? [],
);
_pangeaController.analytics.analyticsStream.stream
.listen(updateAnalyticsData);
}
@override
void dispose() {
_onAnalyticsUpdate?.cancel();
_analyticsUpdateSubscription?.cancel();
super.dispose();
}
/// Update the analytics data shown in the UI. This comes from a
/// combination of stored events and locally cached data.
Future<void> updateAnalyticsData() async {
previousXP = xpPoints;
final List<OneConstructUse> storedUses =
await _pangeaController.analytics.getConstructs();
final List<OneConstructUse> localUses = [];
for (final uses in _pangeaController.analytics.messagesSinceUpdate.values) {
localUses.addAll(uses);
}
if (storedUses.isEmpty) {
words = ConstructListModel(
type: ConstructTypeEnum.vocab,
uses: localUses,
);
errors = ConstructListModel(
type: ConstructTypeEnum.grammar,
uses: localUses,
);
setState(() {});
return;
}
final List<OneConstructUse> allConstructs = [
...storedUses,
...localUses,
];
Future<void> updateAnalyticsData(List<OneConstructUse> constructs) async {
words = ConstructListModel(
type: ConstructTypeEnum.vocab,
uses: allConstructs,
uses: constructs,
);
errors = ConstructListModel(
type: ConstructTypeEnum.grammar,
uses: allConstructs,
uses: constructs,
);
if (loading) loading = false;
if (mounted) setState(() {});
}
@ -119,21 +95,10 @@ class LearningProgressIndicatorsState
}
}
/// Get the total number of xp points, based on the point values of use types.
/// Null if niether words nor error constructs are available.
int? get xpPoints {
if (words == null && errors == null) return null;
if (words == null) return errors!.points;
if (errors == null) return words!.points;
return words!.points + errors!.points;
}
/// Get the current level based on the number of xp points
int get level => (xpPoints ?? 0) ~/ 500;
double get levelBarWidth => FluffyThemes.columnWidth - (32 * 2) - 25;
double get pointsBarWidth {
final percent = ((xpPoints ?? 0) % 500) / 500;
final percent = (totalXP % AnalyticsConstants.xpPerLevel) /
AnalyticsConstants.xpPerLevel;
return levelBarWidth * percent;
}
@ -149,61 +114,42 @@ class LearningProgressIndicatorsState
return colors[level % colors.length];
}
/// Whether to animate the level bar increase. Prevents this bar from seeming to
/// reload each time the user navigates to a different space or back to the chat list.
/// PreviousXP would be null if this widget just mounted. Also handles case of rebuilds
/// without any change in XP points.
bool get animate => previousXP != null && previousXP != xpPoints;
@override
Widget build(BuildContext context) {
if (Matrix.of(context).client.userID == null) {
return const SizedBox();
}
final levelBar = Container(
height: 20,
width: levelBarWidth,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.5),
width: 2,
final progressBar = ProgressBar(
levelBars: [
LevelBarDetails(
fillColor: const Color.fromARGB(255, 0, 190, 83),
currentPoints: totalXP,
),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(AppConfig.borderRadius),
bottomRight: Radius.circular(AppConfig.borderRadius),
LevelBarDetails(
fillColor: Theme.of(context).colorScheme.primary,
currentPoints: serverXP,
),
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
],
progressBarDetails: ProgressBarDetails(
totalWidth: levelBarWidth,
borderColor: Theme.of(context).colorScheme.primary.withOpacity(0.5),
),
);
final xpBarDecoration = BoxDecoration(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(AppConfig.borderRadius),
bottomRight: Radius.circular(AppConfig.borderRadius),
),
color: Theme.of(context).colorScheme.primary,
);
final xpBar = animate
? AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: 16,
width: pointsBarWidth,
decoration: xpBarDecoration,
)
: Container(
height: 16,
width: pointsBarWidth,
decoration: xpBarDecoration,
);
final levelBadge = Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: levelColor(level),
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 5,
offset: const Offset(5, 0),
),
],
),
child: Center(
child: Text(
@ -213,61 +159,71 @@ class LearningProgressIndicatorsState
),
);
return Column(
mainAxisSize: MainAxisSize.min,
return Stack(
alignment: Alignment.topCenter,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(46, 0, 32, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FutureBuilder(
future:
_pangeaController.matrixState.client.getProfileFromUserId(
_pangeaController.matrixState.client.userID!,
),
builder: (context, snapshot) {
final mxid = Matrix.of(context).client.userID ??
L10n.of(context)!.user;
return Avatar(
name: snapshot.data?.displayName ?? mxid.localpart ?? mxid,
mxContent: snapshot.data?.avatarUrl,
size: 40,
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: ProgressIndicatorEnum.values
.where(
(indicator) => indicator != ProgressIndicatorEnum.level,
)
.map(
(indicator) => ProgressIndicatorBadge(
points: getProgressPoints(indicator),
onTap: () {},
progressIndicator: indicator,
loading: loading,
),
)
.toList(),
),
],
),
const Positioned(
child: PointsGainedAnimation(),
),
Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Stack(
alignment: Alignment.center,
children: [
Positioned(left: 16, right: 0, child: levelBar),
Positioned(left: 16, child: xpBar),
Positioned(left: 0, child: levelBadge),
],
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(46, 0, 32, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FutureBuilder(
future: _pangeaController.matrixState.client
.getProfileFromUserId(
_pangeaController.matrixState.client.userID!,
),
builder: (context, snapshot) {
final mxid = Matrix.of(context).client.userID ??
L10n.of(context)!.user;
return Avatar(
name: snapshot.data?.displayName ??
mxid.localpart ??
mxid,
mxContent: snapshot.data?.avatarUrl,
size: 40,
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: ProgressIndicatorEnum.values
.where(
(indicator) =>
indicator != ProgressIndicatorEnum.level,
)
.map(
(indicator) => ProgressIndicatorBadge(
points: getProgressPoints(indicator),
onTap: () {},
progressIndicator: indicator,
loading: loading,
),
)
.toList(),
),
],
),
),
Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Stack(
alignment: Alignment.center,
children: [
Positioned(left: 16, right: 0, child: progressBar),
Positioned(left: 0, child: levelBadge),
],
),
),
const SizedBox(height: 16),
],
),
const SizedBox(height: 16),
],
);
}

View file

@ -20,8 +20,6 @@ import '../common/bot_face_svg.dart';
import 'card_header.dart';
import 'why_button.dart';
const wordMatchResultsCount = 5;
//switch for definition vs correction vs practice
//always show a title
@ -32,12 +30,12 @@ const wordMatchResultsCount = 5;
class SpanCard extends StatefulWidget {
final PangeaController pangeaController = MatrixState.pangeaController;
final SpanCardModel scm;
final String? roomId;
final String roomId;
SpanCard({
super.key,
required this.scm,
this.roomId,
required this.roomId,
});
@override
@ -62,12 +60,16 @@ class SpanCardState extends State<SpanCard> {
//get selected choice
SpanChoice? get selectedChoice {
if (selectedChoiceIndex == null ||
widget.scm.pangeaMatch?.match.choices == null ||
widget.scm.pangeaMatch!.match.choices!.length <= selectedChoiceIndex!) {
if (selectedChoiceIndex == null) return null;
return choiceByIndex(selectedChoiceIndex!);
}
SpanChoice? choiceByIndex(int index) {
if (widget.scm.pangeaMatch?.match.choices == null ||
widget.scm.pangeaMatch!.match.choices!.length <= index) {
return null;
}
return widget.scm.pangeaMatch?.match.choices?[selectedChoiceIndex!];
return widget.scm.pangeaMatch?.match.choices?[index];
}
void fetchSelected() {
@ -76,8 +78,9 @@ class SpanCardState extends State<SpanCard> {
}
if (selectedChoiceIndex == null) {
DateTime? mostRecent;
for (int i = 0; i < widget.scm.pangeaMatch!.match.choices!.length; i++) {
final choice = widget.scm.pangeaMatch?.match.choices![i];
final numChoices = widget.scm.pangeaMatch!.match.choices!.length;
for (int i = 0; i < numChoices; i++) {
final choice = choiceByIndex(i);
if (choice!.timestamp != null &&
(mostRecent == null || choice.timestamp!.isAfter(mostRecent))) {
mostRecent = choice.timestamp;
@ -114,6 +117,58 @@ class SpanCardState extends State<SpanCard> {
}
}
Future<void> onChoiceSelect(int index) async {
selectedChoiceIndex = index;
if (selectedChoice != null) {
selectedChoice!.timestamp = DateTime.now();
selectedChoice!.selected = true;
setState(
() => (selectedChoice!.isBestCorrection
? BotExpression.gold
: BotExpression.surprised),
);
}
}
void onReplaceSelected() {
if (selectedChoice != null) {
final tokens = widget.scm.choreographer.igc.igcTextData
?.matchTokens(widget.scm.matchIndex) ??
[];
MatrixState.pangeaController.myAnalytics.onReplacementSelected(
tokens,
widget.roomId,
selectedChoice!.isBestCorrection,
);
}
widget.scm
.onReplacementSelect(
matchIndex: widget.scm.matchIndex,
choiceIndex: selectedChoiceIndex!,
)
.then((value) {
setState(() {});
});
}
void onIgnoreMatch() {
MatrixState.pAnyState.closeOverlay();
Future.delayed(
Duration.zero,
() {
widget.scm.onIgnore();
final tokens = widget.scm.choreographer.igc.igcTextData
?.matchTokens(widget.scm.matchIndex) ??
[];
MatrixState.pangeaController.myAnalytics.onIgnoreMatch(
tokens,
widget.roomId,
);
},
);
}
@override
Widget build(BuildContext context) {
return WordMatchContent(controller: this);
@ -129,61 +184,6 @@ class WordMatchContent extends StatelessWidget {
super.key,
});
Future<void> onChoiceSelect(int index) async {
controller.selectedChoiceIndex = index;
controller
.widget
.scm
.choreographer
.igc
.igcTextData
?.matches[controller.widget.scm.matchIndex]
.match
.choices?[index]
.timestamp = DateTime.now();
controller
.widget
.scm
.choreographer
.igc
.igcTextData
?.matches[controller.widget.scm.matchIndex]
.match
.choices?[index]
.selected = true;
controller.setState(
() => (controller.currentExpression = controller
.widget
.scm
.choreographer
.igc
.igcTextData!
.matches[controller.widget.scm.matchIndex]
.match
.choices![index]
.isBestCorrection
? BotExpression.gold
: BotExpression.surprised),
);
// if (controller.widget.scm.pangeaMatch.match.choices![index].type ==
// SpanChoiceType.distractor) {
// await controller.getSpanDetails();
// }
// controller.setState(() {});
}
void onReplaceSelected() {
controller.widget.scm
.onReplacementSelect(
matchIndex: controller.widget.scm.matchIndex,
choiceIndex: controller.selectedChoiceIndex!,
)
.then((value) {
controller.setState(() {});
});
}
@override
Widget build(BuildContext context) {
if (controller.widget.scm.pangeaMatch == null) {
@ -248,7 +248,7 @@ class WordMatchContent extends StatelessWidget {
),
)
.toList(),
onPressed: onChoiceSelect,
onPressed: controller.onChoiceSelect,
uniqueKeyForLayerLink: (int index) => "wordMatch$index",
selectedChoiceIndex: controller.selectedChoiceIndex,
),
@ -272,13 +272,7 @@ class WordMatchContent extends StatelessWidget {
AppConfig.primaryColor.withOpacity(0.1),
),
),
onPressed: () {
MatrixState.pAnyState.closeOverlay();
Future.delayed(
Duration.zero,
() => controller.widget.scm.onIgnore(),
);
},
onPressed: controller.onIgnoreMatch,
child: Center(
child: Text(L10n.of(context)!.ignoreInThisText),
),
@ -292,7 +286,7 @@ class WordMatchContent extends StatelessWidget {
opacity: controller.selectedChoiceIndex != null ? 1.0 : 0.5,
child: TextButton(
onPressed: controller.selectedChoiceIndex != null
? onReplaceSelected
? controller.onReplaceSelected
: null,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all<Color>(
@ -352,7 +346,6 @@ class WordMatchContent extends StatelessWidget {
} on Exception catch (e) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: StackTrace.current);
print(e);
rethrow;
}
}

View file

@ -1,184 +0,0 @@
//Possible actions/effects from cards
// Nothing
// useType of viewed definitions
// SpanChoice of text in message from options
// Call to server for additional/followup info
import 'package:collection/collection.dart';
import '../../enum/span_data_type.dart';
class SpanData {
String? message;
String? shortMessage;
List<SpanChoice>? choices;
List<Replacement>? replacements;
int offset;
int length;
String fullText;
Context? context;
SpanDataTypeEnum type;
Rule? rule;
SpanData({
this.message,
this.shortMessage,
this.choices,
this.replacements,
required this.offset,
required this.length,
required this.fullText,
this.context,
required this.type,
this.rule,
});
factory SpanData.fromJson(Map<String, dynamic> json) {
return SpanData(
message: json['message'],
shortMessage: json['shortMessage'],
choices: json['choices'] != null
? List<SpanChoice>.from(
json['choices'].map((x) => SpanChoice.fromJson(x)),
)
: null,
replacements: json['replacements'] != null
? List<Replacement>.from(
json['replacements'].map((x) => Replacement.fromJson(x)),
)
: null,
offset: json['offset'],
length: json['length'],
fullText: json['full_text'],
context:
json['context'] != null ? Context.fromJson(json['context']) : null,
type: SpanDataTypeEnum.values.firstWhereOrNull(
(e) => e.toString() == 'SpanDataTypeEnum.${json['type']}',
) ??
SpanDataTypeEnum.correction,
rule: json['rule'] != null ? Rule.fromJson(json['rule']) : null,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['message'] = message;
data['shortMessage'] = shortMessage;
if (choices != null) {
data['choices'] = choices!.map((x) => x.toJson()).toList();
}
if (replacements != null) {
data['replacements'] = replacements!.map((x) => x.toJson()).toList();
}
data['offset'] = offset;
data['length'] = length;
data['full_text'] = fullText;
if (context != null) {
data['context'] = context!.toJson();
}
data['type'] = type.toString().split('.').last;
if (rule != null) {
data['rule'] = rule!.toJson();
}
return data;
}
}
class Context {
String sentence;
int offset;
int length;
Context({required this.sentence, required this.offset, required this.length});
factory Context.fromJson(Map<String, dynamic> json) {
return Context(
sentence: json['sentence'],
offset: json['offset'],
length: json['length'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['sentence'] = sentence;
data['offset'] = offset;
data['length'] = length;
return data;
}
}
class SpanChoice {
String value;
bool selected;
SpanChoice({required this.value, required this.selected});
factory SpanChoice.fromJson(Map<String, dynamic> json) {
return SpanChoice(
value: json['value'],
selected: json['selected'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['value'] = value;
data['selected'] = selected;
return data;
}
}
class Replacement {
String value;
Replacement({required this.value});
factory Replacement.fromJson(Map<String, dynamic> json) {
return Replacement(
value: json['value'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['value'] = value;
return data;
}
}
class Rule {
String id;
Rule({required this.id});
factory Rule.fromJson(Map<String, dynamic> json) {
return Rule(
id: json['id'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
return data;
}
}
class SpanDataType {
String type;
SpanDataType({required this.type});
factory SpanDataType.fromJson(Map<String, dynamic> json) {
return SpanDataType(
type: json['type'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['type'] = type;
return data;
}
}

View file

@ -91,20 +91,28 @@ Future<void> pLanguageDialog(
context: context,
future: () async {
try {
pangeaController.userController.updateProfile(
(profile) {
profile.userSettings.sourceLanguage =
selectedSourceLanguage.langCode;
profile.userSettings.targetLanguage =
selectedTargetLanguage.langCode;
return profile;
},
waitForDataInSync: true,
).then((_) {
pangeaController.myAnalytics
.updateAnalytics()
.then((_) {
pangeaController.userController.updateProfile(
(profile) {
profile.userSettings.sourceLanguage =
selectedSourceLanguage.langCode;
profile.userSettings.targetLanguage =
selectedTargetLanguage.langCode;
return profile;
},
waitForDataInSync: true,
);
}).then((_) {
// if the profile update is successful, reset cached analytics
// data, since analytics data corresponds to the user's L2
pangeaController.myAnalytics.clearCache();
pangeaController.analytics.clearCache();
pangeaController.myAnalytics.dispose();
pangeaController.analytics.dispose();
pangeaController.myAnalytics.initialize();
pangeaController.analytics.initialize();
Navigator.pop(context);
});
} catch (err, s) {