Merge pull request #4824 from pangeachat/4819-tokenfeedback-request-doesnt-update-lemma_info

fix: always pass lemma info and phonetic transcription in token feedb…
This commit is contained in:
ggurdin 2025-12-09 16:46:50 -05:00 committed by GitHub
commit be0ed2fa1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 748 additions and 341 deletions

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
@ -88,6 +90,10 @@ abstract class AsyncLoader<T> {
final result = await fetch();
if (_disposed) return;
state.value = AsyncState.loaded(result);
} on HttpException catch (e) {
if (!_disposed) {
state.value = AsyncState.error(e);
}
} catch (e, s) {
ErrorHandler.logError(
e: e,

View file

@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
@ -150,7 +151,7 @@ class ConstructIdentifier {
uses: [],
);
LemmaInfoRequest get _lemmaInfoRequest => LemmaInfoRequest(
LemmaInfoRequest get lemmaInfoRequest => LemmaInfoRequest(
partOfSpeech: category,
lemmaLang:
MatrixState.pangeaController.userController.userL2?.langCodeShort ??
@ -162,8 +163,9 @@ class ConstructIdentifier {
);
/// [lemmmaLang] if not set, assumed to be userL2
Future<LemmaInfoResponse> getLemmaInfo() => LemmaInfoRepo.get(
_lemmaInfoRequest,
Future<Result<LemmaInfoResponse>> getLemmaInfo() => LemmaInfoRepo.get(
MatrixState.pangeaController.userController.accessToken,
lemmaInfoRequest,
);
List<String> get userSetEmoji => userLemmaInfo.emojis ?? [];

View file

@ -22,4 +22,12 @@ class LanguageArc {
'l2': l2.toJson(),
};
}
@override
int get hashCode => l1.hashCode ^ l2.hashCode;
@override
bool operator ==(Object other) {
return other is LanguageArc && other.l1 == l1 && other.l2 == l2;
}
}

View file

@ -1,48 +1,105 @@
import 'dart:convert';
import 'dart:io';
import 'package:async/async.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/widgets/matrix.dart';
class _LemmaInfoCacheItem {
final Future<Result<LemmaInfoResponse>> resultFuture;
final DateTime timestamp;
const _LemmaInfoCacheItem({
required this.resultFuture,
required this.timestamp,
});
}
class LemmaInfoRepo {
static final GetStorage _lemmaStorage = GetStorage('lemma_storage');
// In-memory cache
static final Map<String, _LemmaInfoCacheItem> _cache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
static void set(LemmaInfoRequest request, LemmaInfoResponse response) {
// set expireAt if not set
response.expireAt ??= DateTime.now().add(const Duration(days: 100));
_lemmaStorage.write(request.storageKey, response.toJson());
}
static LemmaInfoResponse? getCached(LemmaInfoRequest request) {
final cachedJson = _lemmaStorage.read(request.storageKey);
final cached =
cachedJson == null ? null : LemmaInfoResponse.fromJson(cachedJson);
// Persistent storage
static final GetStorage _storage = GetStorage('lemma_storage');
/// Public entry point
static Future<Result<LemmaInfoResponse>> get(
String accessToken,
LemmaInfoRequest request,
) {
// 1. Try memory cache
final cached = _getCached(request);
if (cached != null) {
if (DateTime.now().isBefore(cached.expireAt!)) {
return cached;
} else {
_lemmaStorage.remove(request.storageKey);
}
return cached;
}
return null;
// 2. Try disk cache
final stored = _getStored(request);
if (stored != null) {
return Future.value(Result.value(stored));
}
// 3. Fetch from network (safe future)
final future = _safeFetch(accessToken, request);
// 4. Save to in-memory cache
_cache[request.hashCode.toString()] = _LemmaInfoCacheItem(
resultFuture: future,
timestamp: DateTime.now(),
);
// 5. Write to disk *after* the fetch finishes, without rethrowing
writeToDisk(request, future);
return future;
}
/// Get lemma info, prefering user set data over fetched data
static Future<LemmaInfoResponse> get(LemmaInfoRequest request) async {
final cached = getCached(request);
if (cached != null) return cached;
static Future<void> set(
LemmaInfoRequest request,
LemmaInfoResponse resultFuture,
) async {
final key = request.hashCode.toString();
try {
await _storage.write(key, resultFuture.toJson());
_cache.remove(key); // Invalidate in-memory cache
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {'lemma': request.lemma},
);
}
}
final Requests req = Requests(
static Future<Result<LemmaInfoResponse>> _safeFetch(
String token,
LemmaInfoRequest request,
) async {
try {
final resp = await _fetch(token, request);
return Result.value(resp);
} catch (e, s) {
// Ensure error is logged and converted to a Result
ErrorHandler.logError(e: e, s: s, data: request.toJson());
return Result.error(e);
}
}
static Future<LemmaInfoResponse> _fetch(
String accessToken,
LemmaInfoRequest request,
) async {
final req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: MatrixState.pangeaController.userController.accessToken,
accessToken: accessToken,
);
final Response res = await req.post(
@ -50,10 +107,59 @@ class LemmaInfoRepo {
body: request.toJson(),
);
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = LemmaInfoResponse.fromJson(decodedBody);
if (res.statusCode != 200) {
throw HttpException(
'Failed to fetch lemma info: ${res.statusCode} ${res.reasonPhrase}',
);
}
set(request, response);
return response;
return LemmaInfoResponse.fromJson(
jsonDecode(utf8.decode(res.bodyBytes)),
);
}
static Future<Result<LemmaInfoResponse>>? _getCached(
LemmaInfoRequest request,
) {
final now = DateTime.now();
final key = request.hashCode.toString();
// Remove stale entries first
_cache.removeWhere(
(_, item) => now.difference(item.timestamp) >= _cacheDuration,
);
final item = _cache[key];
return item?.resultFuture;
}
static Future<void> writeToDisk(
LemmaInfoRequest request,
Future<Result<LemmaInfoResponse>> resultFuture,
) async {
final result = await resultFuture; // SAFE: never throws
if (!result.isValue) return; // only cache successful responses
await set(request, result.asValue!.value);
}
static LemmaInfoResponse? _getStored(
LemmaInfoRequest request,
) {
final key = request.hashCode.toString();
try {
final entry = _storage.read(key);
if (entry == null) return null;
return LemmaInfoResponse.fromJson(entry);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {'lemma': request.lemma},
);
_storage.remove(key);
return null;
}
}
}

View file

@ -3,12 +3,10 @@ import 'package:fluffychat/pangea/events/models/content_feedback.dart';
class LemmaInfoResponse implements JsonSerializable {
final List<String> emoji;
final String meaning;
DateTime? expireAt;
LemmaInfoResponse({
required this.emoji,
required this.meaning,
this.expireAt,
});
factory LemmaInfoResponse.fromJson(Map<String, dynamic> json) {
@ -16,18 +14,19 @@ class LemmaInfoResponse implements JsonSerializable {
// NOTE: This is a workaround for the fact that the server sometimes sends more than 3 emojis
emoji: (json['emoji'] as List<dynamic>).map((e) => e as String).toList(),
meaning: json['meaning'] as String,
expireAt: json['expireAt'] == null
? null
: DateTime.parse(json['expireAt'] as String),
);
}
static LemmaInfoResponse get error => LemmaInfoResponse(
emoji: [],
meaning: 'ERROR',
);
@override
Map<String, dynamic> toJson() {
return {
'emoji': emoji,
'meaning': meaning,
'expireAt': expireAt?.toIso8601String(),
};
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/languages/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
@ -7,6 +8,25 @@ import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/widgets/matrix.dart';
class _LemmaMeaningLoader extends AsyncLoader<LemmaInfoResponse> {
final LemmaInfoRequest request;
_LemmaMeaningLoader(this.request) : super();
@override
Future<LemmaInfoResponse> fetch() async {
final result = await LemmaInfoRepo.get(
MatrixState.pangeaController.userController.accessToken,
request,
);
if (result.isError) {
throw result.asError!.error;
}
return result.asValue!.value;
}
}
class LemmaMeaningBuilder extends StatefulWidget {
final String langCode;
final ConstructIdentifier constructId;
@ -27,14 +47,12 @@ class LemmaMeaningBuilder extends StatefulWidget {
}
class LemmaMeaningBuilderState extends State<LemmaMeaningBuilder> {
LemmaInfoResponse? lemmaInfo;
bool isLoading = true;
Object? error;
late _LemmaMeaningLoader _loader;
@override
void initState() {
super.initState();
_fetchLemmaMeaning();
_reload();
}
@override
@ -42,10 +60,25 @@ class LemmaMeaningBuilderState extends State<LemmaMeaningBuilder> {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructId != widget.constructId ||
oldWidget.langCode != widget.langCode) {
_fetchLemmaMeaning();
_loader.dispose();
_reload();
}
}
@override
void dispose() {
_loader.dispose();
super.dispose();
}
bool get isLoading => _loader.isLoading;
bool get isError => _loader.isError;
Object? get error =>
isError ? (_loader.state.value as AsyncError).error : null;
LemmaInfoResponse? get lemmaInfo => _loader.value;
LemmaInfoRequest get _request => LemmaInfoRequest(
lemma: widget.constructId.lemma,
partOfSpeech: widget.constructId.category,
@ -54,27 +87,19 @@ class LemmaMeaningBuilderState extends State<LemmaMeaningBuilder> {
LanguageKeys.defaultLanguage,
);
Future<void> _fetchLemmaMeaning() async {
setState(() {
isLoading = true;
error = null;
});
try {
final resp = await LemmaInfoRepo.get(_request);
lemmaInfo = resp;
} catch (e) {
error = e;
} finally {
if (mounted) setState(() => isLoading = false);
}
void _reload() {
_loader = _LemmaMeaningLoader(_request);
_loader.load();
}
@override
Widget build(BuildContext context) {
return widget.builder(
context,
this,
return ValueListenableBuilder(
valueListenable: _loader.state,
builder: (context, _, __) => widget.builder(
context,
this,
),
);
}
}

View file

@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/languages/language_arc_model.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_request.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'phonetic_transcription_repo.dart';
class _TranscriptLoader extends AsyncLoader<String> {
final PhoneticTranscriptionRequest request;
_TranscriptLoader(this.request) : super();
@override
Future<String> fetch() async {
final resp = await PhoneticTranscriptionRepo.get(
MatrixState.pangeaController.userController.accessToken,
request,
);
if (resp.isError) {
throw resp.asError!.error;
}
return resp.asValue!.value.phoneticTranscriptionResult.phoneticTranscription
.first.phoneticL1Transcription.content;
}
}
class PhoneticTranscriptionBuilder extends StatefulWidget {
final LanguageModel textLanguage;
final String text;
final Widget Function(
BuildContext context,
PhoneticTranscriptionBuilderState controller,
) builder;
const PhoneticTranscriptionBuilder({
super.key,
required this.textLanguage,
required this.text,
required this.builder,
});
@override
PhoneticTranscriptionBuilderState createState() =>
PhoneticTranscriptionBuilderState();
}
class PhoneticTranscriptionBuilderState
extends State<PhoneticTranscriptionBuilder> {
late _TranscriptLoader _loader;
@override
void initState() {
super.initState();
_reload();
}
@override
void didUpdateWidget(covariant PhoneticTranscriptionBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text ||
oldWidget.textLanguage != widget.textLanguage) {
_loader.dispose();
_reload();
}
}
@override
void dispose() {
_loader.dispose();
super.dispose();
}
bool get isLoading => _loader.isLoading;
bool get isError => _loader.isError;
Object? get error =>
isError ? (_loader.state.value as AsyncError).error : null;
String? get transcription => _loader.value;
PhoneticTranscriptionRequest get _transcriptRequest =>
PhoneticTranscriptionRequest(
arc: LanguageArc(
l1: MatrixState.pangeaController.userController.userL1!,
l2: widget.textLanguage,
),
content: PangeaTokenText.fromString(widget.text),
);
void _reload() {
_loader = _TranscriptLoader(_transcriptRequest);
_loader.load();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: _loader.state,
builder: (context, _, __) => widget.builder(
context,
this,
),
);
}
}

View file

@ -1,8 +1,7 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'dart:io';
import 'package:async/async.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
@ -12,39 +11,95 @@ import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_request.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_response.dart';
import 'package:fluffychat/widgets/matrix.dart';
class _PhoneticTranscriptionCacheItem {
final Future<Result<PhoneticTranscriptionResponse>> resultFuture;
final DateTime timestamp;
const _PhoneticTranscriptionCacheItem({
required this.resultFuture,
required this.timestamp,
});
}
class PhoneticTranscriptionRepo {
// In-memory cache
static final Map<String, _PhoneticTranscriptionCacheItem> _cache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
// Persistent storage
static final GetStorage _storage =
GetStorage('phonetic_transcription_storage');
static Future<Result<PhoneticTranscriptionResponse>> get(
String accessToken,
PhoneticTranscriptionRequest request,
) {
// 1. Try memory cache
final cached = _getCached(request);
if (cached != null) {
return cached;
}
// 2. Try disk cache
final stored = _getStored(request);
if (stored != null) {
return Future.value(Result.value(stored));
}
// 3. Fetch from network (safe future)
final future = _safeFetch(accessToken, request);
// 4. Save to in-memory cache
_cache[request.hashCode.toString()] = _PhoneticTranscriptionCacheItem(
resultFuture: future,
timestamp: DateTime.now(),
);
// 5. Write to disk *after* the fetch finishes, without rethrowing
writeToDisk(request, future);
return future;
}
static Future<void> set(
PhoneticTranscriptionRequest request,
PhoneticTranscriptionResponse response,
PhoneticTranscriptionResponse resultFuture,
) async {
response.expireAt ??= DateTime.now().add(const Duration(days: 100));
await _storage.write(request.storageKey, response.toJson());
final key = request.hashCode.toString();
try {
await _storage.write(key, resultFuture.toJson());
_cache.remove(key); // Invalidate in-memory cache
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {'request': request.toJson()},
);
}
}
static Future<Result<PhoneticTranscriptionResponse>> _safeFetch(
String token,
PhoneticTranscriptionRequest request,
) async {
try {
final resp = await _fetch(token, request);
return Result.value(resp);
} catch (e, s) {
// Ensure error is logged and converted to a Result
ErrorHandler.logError(e: e, s: s, data: request.toJson());
return Result.error(e);
}
}
static Future<PhoneticTranscriptionResponse> _fetch(
String accessToken,
PhoneticTranscriptionRequest request,
) async {
final cachedJson = _storage.read(request.storageKey);
final cached = cachedJson == null
? null
: PhoneticTranscriptionResponse.fromJson(cachedJson);
if (cached != null) {
if (DateTime.now().isBefore(cached.expireAt!)) {
return cached;
} else {
_storage.remove(request.storageKey);
}
}
final Requests req = Requests(
final req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: MatrixState.pangeaController.userController.accessToken,
accessToken: accessToken,
);
final Response res = await req.post(
@ -52,21 +107,59 @@ class PhoneticTranscriptionRepo {
body: request.toJson(),
);
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = PhoneticTranscriptionResponse.fromJson(decodedBody);
set(request, response);
return response;
if (res.statusCode != 200) {
throw HttpException(
'Failed to fetch phonetic transcription: ${res.statusCode} ${res.reasonPhrase}',
);
}
return PhoneticTranscriptionResponse.fromJson(
jsonDecode(utf8.decode(res.bodyBytes)),
);
}
static Future<PhoneticTranscriptionResponse> get(
static Future<Result<PhoneticTranscriptionResponse>>? _getCached(
PhoneticTranscriptionRequest request,
) {
final now = DateTime.now();
final key = request.hashCode.toString();
// Remove stale entries first
_cache.removeWhere(
(_, item) => now.difference(item.timestamp) >= _cacheDuration,
);
final item = _cache[key];
return item?.resultFuture;
}
static Future<void> writeToDisk(
PhoneticTranscriptionRequest request,
Future<Result<PhoneticTranscriptionResponse>> resultFuture,
) async {
final result = await resultFuture; // SAFE: never throws
if (!result.isValue) return; // only cache successful responses
await set(request, result.asValue!.value);
}
static PhoneticTranscriptionResponse? _getStored(
PhoneticTranscriptionRequest request,
) {
final key = request.hashCode.toString();
try {
return await _fetch(request);
} catch (e) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, data: request.toJson());
rethrow;
final entry = _storage.read(key);
if (entry == null) return null;
return PhoneticTranscriptionResponse.fromJson(entry);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {'request': request.toJson()},
);
_storage.remove(key);
return null;
}
}
}

View file

@ -30,4 +30,16 @@ class PhoneticTranscriptionRequest {
}
String get storageKey => '${arc.l1}-${arc.l2}-${content.hashCode}';
@override
int get hashCode =>
content.hashCode ^ arc.hashCode ^ requiresTokenization.hashCode;
@override
bool operator ==(Object other) {
return other is PhoneticTranscriptionRequest &&
other.content == content &&
other.arc == arc &&
other.requiresTokenization == requiresTokenization;
}
}

View file

@ -102,14 +102,12 @@ class PhoneticTranscriptionResponse {
final Map<String, dynamic>
tokenization; // You can define a typesafe model if needed
final PhoneticTranscription phoneticTranscriptionResult;
DateTime? expireAt;
PhoneticTranscriptionResponse({
required this.arc,
required this.content,
required this.tokenization,
required this.phoneticTranscriptionResult,
this.expireAt,
});
factory PhoneticTranscriptionResponse.fromJson(Map<String, dynamic> json) {
@ -121,9 +119,6 @@ class PhoneticTranscriptionResponse {
phoneticTranscriptionResult: PhoneticTranscription.fromJson(
json['phonetic_transcription_result'] as Map<String, dynamic>,
),
expireAt: json['expireAt'] == null
? null
: DateTime.parse(json['expireAt'] as String),
);
}
@ -133,7 +128,6 @@ class PhoneticTranscriptionResponse {
'content': content.toJson(),
'tokenization': tokenization,
'phonetic_transcription_result': phoneticTranscriptionResult.toJson(),
'expireAt': expireAt?.toIso8601String(),
};
}

View file

@ -4,13 +4,9 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/languages/language_arc_model.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_repo.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_request.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_builder.dart';
import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -43,79 +39,6 @@ class PhoneticTranscriptionWidget extends StatefulWidget {
class _PhoneticTranscriptionWidgetState
extends State<PhoneticTranscriptionWidget> {
bool _isPlaying = false;
bool _isLoading = false;
Object? _error;
String? _transcription;
@override
void initState() {
super.initState();
_fetchTranscription();
}
@override
void didUpdateWidget(
covariant PhoneticTranscriptionWidget oldWidget,
) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text ||
oldWidget.textLanguage != widget.textLanguage) {
_fetchTranscription();
}
}
Future<void> _fetchTranscription() async {
try {
setState(() {
_isLoading = true;
_error = null;
_transcription = null;
});
if (MatrixState.pangeaController.userController.userL1 == null) {
ErrorHandler.logError(
e: Exception('User L1 is not set'),
data: {
'text': widget.text,
'textLanguageCode': widget.textLanguage.langCode,
},
);
_error = Exception('User L1 is not set');
return;
}
final req = PhoneticTranscriptionRequest(
arc: LanguageArc(
l1: MatrixState.pangeaController.userController.userL1!,
l2: widget.textLanguage,
),
content: PangeaTokenText.fromString(widget.text),
// arc can be omitted for default empty map
);
final res = await PhoneticTranscriptionRepo.get(req);
_transcription = res.phoneticTranscriptionResult.phoneticTranscription
.first.phoneticL1Transcription.content;
} catch (e, s) {
_error = e;
if (e is! UnsubscribedException) {
ErrorHandler.logError(
e: e,
s: s,
data: {
'text': widget.text,
'textLanguageCode': widget.textLanguage.langCode,
},
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
widget.onTranscriptionFetched?.call();
});
}
}
}
Future<void> _handleAudioTap() async {
if (_isPlaying) {
@ -156,14 +79,15 @@ class _PhoneticTranscriptionWidgetState
link: MatrixState.pAnyState
.layerLinkAndKey("phonetic-transcription-${widget.text}")
.link,
child: Row(
child: PhoneticTranscriptionBuilder(
key: MatrixState.pAnyState
.layerLinkAndKey("phonetic-transcription-${widget.text}")
.key,
mainAxisSize: MainAxisSize.min,
children: [
if (_error != null)
_error is UnsubscribedException
textLanguage: widget.textLanguage,
text: widget.text,
builder: (context, controller) {
if (controller.isError) {
return controller.error is UnsubscribedException
? ErrorIndicator(
message: L10n.of(context)
.subscribeToUnlockTranscriptions,
@ -176,37 +100,44 @@ class _PhoneticTranscriptionWidgetState
: ErrorIndicator(
message:
L10n.of(context).failedToFetchTranscription,
)
else if (_isLoading || _transcription == null)
const SizedBox(
);
}
if (controller.isLoading ||
controller.transcription == null) {
return const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(),
)
else
Flexible(
child: Text(
_transcription!,
textScaler: TextScaler.noScaling,
style: widget.style ??
Theme.of(context).textTheme.bodyMedium,
);
}
return Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
controller.transcription!,
textScaler: TextScaler.noScaling,
style: widget.style ??
Theme.of(context).textTheme.bodyMedium,
),
),
),
if (_transcription != null && _error == null)
const SizedBox(width: 8),
if (_transcription != null && _error == null)
Tooltip(
message: _isPlaying
? L10n.of(context).stop
: L10n.of(context).playAudio,
child: Icon(
_isPlaying ? Icons.pause_outlined : Icons.volume_up,
size: widget.iconSize ?? 24,
color: widget.iconColor ??
Theme.of(context).iconTheme.color,
Tooltip(
message: _isPlaying
? L10n.of(context).stop
: L10n.of(context).playAudio,
child: Icon(
_isPlaying ? Icons.pause_outlined : Icons.volume_up,
size: widget.iconSize ?? 24,
color: widget.iconColor ??
Theme.of(context).iconTheme.color,
),
),
),
],
],
);
},
),
),
),

View file

@ -1,3 +1,5 @@
import 'package:async/async.dart';
import 'package:fluffychat/pangea/constructs/construct_form.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
@ -35,15 +37,20 @@ class EmojiActivityGenerator {
}
}
final List<Future<LemmaInfoResponse>> lemmaInfoFutures = missingEmojis
.map((token) => token.vocabConstructID.getLemmaInfo())
.toList();
final List<Future<Result<LemmaInfoResponse>>> lemmaInfoFutures =
missingEmojis
.map((token) => token.vocabConstructID.getLemmaInfo())
.toList();
final List<LemmaInfoResponse> lemmaInfos =
final List<Result<LemmaInfoResponse>> lemmaInfos =
await Future.wait(lemmaInfoFutures);
for (int i = 0; i < missingEmojis.length; i++) {
final e = lemmaInfos[i].emoji.firstWhere(
if (lemmaInfos[i].isError) {
throw lemmaInfos[i].asError!.error;
}
final e = lemmaInfos[i].asValue!.value.emoji.firstWhere(
(e) => !usedEmojis.contains(e),
orElse: () => throw Exception(
"Not enough unique emojis for tokens in message",

View file

@ -1,26 +1,34 @@
import 'dart:async';
import 'package:async/async.dart';
import 'package:fluffychat/pangea/constructs/construct_form.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_match.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class LemmaMeaningActivityGenerator {
static Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
final List<Future<LemmaInfoResponse>> lemmaInfoFutures = req.targetTokens
final List<Future<Result<LemmaInfoResponse>>> lemmaInfoFutures = req
.targetTokens
.map((token) => token.vocabConstructID.getLemmaInfo())
.toList();
final List<LemmaInfoResponse> lemmaInfos =
final List<Result<LemmaInfoResponse>> lemmaInfos =
await Future.wait(lemmaInfoFutures);
if (lemmaInfos.any((result) => result.isError)) {
throw lemmaInfos.firstWhere((result) => result.isError).error!;
}
final Map<ConstructForm, List<String>> matchInfo = Map.fromIterables(
req.targetTokens.map((token) => token.vocabForm),
lemmaInfos.map((lemmaInfo) => [lemmaInfo.meaning]),
lemmaInfos.map((lemmaInfo) => [lemmaInfo.asValue!.value.meaning]),
);
return MessageActivityResponse(

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
@ -69,6 +70,8 @@ class PracticeRepo {
_setCached(req, res);
return Result.value(res.activity);
} on HttpException catch (e, s) {
return Result.error(e, s);
} catch (e, s) {
ErrorHandler.logError(
e: e,

View file

@ -8,8 +8,8 @@ import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_arc_model.dart';
import 'package:fluffychat/pangea/languages/p_language_store.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_repo.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_request.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_response.dart';
@ -115,19 +115,11 @@ class TokenInfoFeedbackDialog extends StatelessWidget {
Future<void> _updateLemmaInfo(
PangeaToken token,
LemmaInfoResponse response,
) async {
final construct = token.vocabConstructID;
final currentLemmaInfo = construct.userLemmaInfo;
final updatedLemmaInfo = UserSetLemmaInfo(
meaning: response.meaning,
emojis: response.emoji,
);
if (currentLemmaInfo != updatedLemmaInfo) {
await construct.setUserLemmaInfo(updatedLemmaInfo);
}
}
) =>
LemmaInfoRepo.set(
token.vocabConstructID.lemmaInfoRequest,
response,
);
Future<void> _updatePhoneticTranscription(
PhoneticTranscriptionResponse response,

View file

@ -8,8 +8,8 @@ class TokenInfoFeedbackRequestData {
final String detectedLanguage;
final List<PangeaToken> tokens;
final int selectedToken;
final LemmaInfoResponse? lemmaInfo;
final String? phonetics;
final LemmaInfoResponse lemmaInfo;
final String phonetics;
final String wordCardL1;
TokenInfoFeedbackRequestData({
@ -19,8 +19,8 @@ class TokenInfoFeedbackRequestData {
required this.detectedLanguage,
required this.tokens,
required this.selectedToken,
this.lemmaInfo,
this.phonetics,
required this.lemmaInfo,
required this.phonetics,
required this.wordCardL1,
});
@ -67,7 +67,7 @@ class TokenInfoFeedbackRequest {
'detected_language': data.detectedLanguage,
'tokens': data.tokens.map((token) => token.toJson()).toList(),
'selected_token': data.selectedToken,
'lemma_info': data.lemmaInfo?.toJson(),
'lemma_info': data.lemmaInfo.toJson(),
'phonetics': data.phonetics,
'user_feedback': userFeedback,
'word_card_l1': data.wordCardL1,

View file

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
class LemmaMeaningDisplay extends StatelessWidget {
final String langCode;
final ConstructIdentifier constructId;
final String text;
const LemmaMeaningDisplay({
super.key,
required this.langCode,
required this.constructId,
required this.text,
});
@override
Widget build(BuildContext context) {
return LemmaMeaningBuilder(
langCode: langCode,
constructId: constructId,
builder: (context, controller) {
if (controller.isError) {
return ErrorIndicator(
message: L10n.of(context).errorFetchingDefinition,
style: const TextStyle(fontSize: 14.0),
);
}
if (controller.isLoading || controller.lemmaInfo == null) {
return const CircularProgressIndicator.adaptive();
}
if (constructId.lemma.toLowerCase() == text.toLowerCase()) {
return Text(
controller.lemmaInfo!.meaning,
style: const TextStyle(
fontSize: 14.0,
),
textAlign: TextAlign.center,
);
}
return RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style.copyWith(
fontSize: 14.0,
),
children: [
TextSpan(
text: constructId.lemma,
),
const WidgetSpan(
child: SizedBox(width: 8.0),
),
const TextSpan(text: ":"),
const WidgetSpan(
child: SizedBox(width: 8.0),
),
TextSpan(
text: controller.lemmaInfo!.meaning,
),
],
),
);
},
);
}
}

View file

@ -4,21 +4,26 @@ import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance/lemma_emoji_picker.dart';
class LemmaReactionPicker extends StatelessWidget {
final List<String> emojis;
final bool loading;
final Event? event;
final ConstructIdentifier construct;
final String langCode;
const LemmaReactionPicker({
super.key,
required this.emojis,
required this.loading,
required this.event,
required this.construct,
required this.langCode,
this.event,
});
Future<void> setEmoji(String emoji) async {
Future<void> setEmoji(
String emoji,
List<String> emojis,
) async {
if (event?.room.timeline == null) {
throw Exception("Timeline is null in reaction picker");
}
@ -63,33 +68,44 @@ class LemmaReactionPicker extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sentReactions = <String>{};
if (event?.room.timeline != null) {
sentReactions.addAll(
event!
.aggregatedEvents(
event!.room.timeline!,
RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
)
.map(
(event) => event.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key'),
)
.whereType<String>(),
);
}
return LemmaMeaningBuilder(
langCode: langCode,
constructId: construct,
builder: (context, controller) {
final sentReactions = <String>{};
if (event?.room.timeline != null) {
sentReactions.addAll(
event!
.aggregatedEvents(
event!.room.timeline!,
RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
)
.map(
(event) => event.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key'),
)
.whereType<String>(),
);
}
return LemmaEmojiPicker(
emojis: emojis,
onSelect: event?.room.timeline != null ? setEmoji : null,
disabled: (emoji) => sentReactions.contains(emoji),
loading: loading,
return LemmaEmojiPicker(
emojis: controller.lemmaInfo?.emoji ?? [],
onSelect: event?.room.timeline != null
? (emoji) => setEmoji(
emoji,
controller.lemmaInfo?.emoji ?? [],
)
: null,
disabled: (emoji) => sentReactions.contains(emoji),
loading: controller.isLoading,
);
},
);
}
}

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:matrix/matrix_api_lite/model/message_types.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.dart';
import 'package:fluffychat/pangea/toolbar/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/word_card/word_zoom_widget.dart';
@ -50,7 +51,7 @@ class ReadingAssistanceContent extends StatelessWidget {
onClose: () => overlayController.updateSelectedSpan(null),
langCode: overlayController.pangeaMessageEvent.messageDisplayLangCode,
onDismissNewWordOverlay: () => overlayController.setState(() {}),
onFlagTokenInfo: () {
onFlagTokenInfo: (LemmaInfoResponse lemmaInfo, String phonetics) {
if (selectedTokenIndex < 0) return;
final requestData = TokenInfoFeedbackRequestData(
userId: Matrix.of(context).client.userID!,
@ -61,6 +62,8 @@ class ReadingAssistanceContent extends StatelessWidget {
tokens: tokens ?? [],
selectedToken: selectedTokenIndex,
wordCardL1: MatrixState.pangeaController.userController.userL1Code!,
lemmaInfo: lemmaInfo,
phonetics: phonetics,
);
overlayController.widget.chatController.showTokenFeedbackDialog(
requestData,

View file

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_builder.dart';
class TokenFeedbackButton extends StatelessWidget {
final LanguageModel textLanguage;
final ConstructIdentifier constructId;
final String text;
final Function(LemmaInfoResponse, String) onFlagTokenInfo;
const TokenFeedbackButton({
super.key,
required this.textLanguage,
required this.constructId,
required this.text,
required this.onFlagTokenInfo,
});
@override
Widget build(BuildContext context) {
return LemmaMeaningBuilder(
langCode: textLanguage.langCode,
constructId: constructId,
builder: (context, lemmaController) {
return PhoneticTranscriptionBuilder(
textLanguage: textLanguage,
text: text,
builder: (context, transcriptController) {
final enabled = (lemmaController.lemmaInfo != null ||
lemmaController.isError) &&
(transcriptController.transcription != null ||
transcriptController.isError);
final lemmaInfo =
lemmaController.lemmaInfo ?? LemmaInfoResponse.error;
final transcript = transcriptController.transcription ?? 'ERROR';
return IconButton(
icon: const Icon(Icons.flag_outlined),
onPressed: enabled
? () {
onFlagTokenInfo(
lemmaInfo,
transcript,
);
}
: null,
tooltip: enabled ? L10n.of(context).reportWordIssueTooltip : null,
);
},
);
},
);
}
}

View file

@ -3,18 +3,18 @@ import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/common/widgets/word_audio_button.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/languages/p_language_store.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance/new_word_overlay.dart';
import 'package:fluffychat/pangea/toolbar/word_card/lemma_meaning_display.dart';
import 'package:fluffychat/pangea/toolbar/word_card/lemma_reaction_picker.dart';
import 'package:fluffychat/pangea/toolbar/word_card/message_unsubscribed_card.dart';
import 'package:fluffychat/pangea/toolbar/word_card/token_feedback_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
class WordZoomWidget extends StatelessWidget {
@ -28,10 +28,7 @@ class WordZoomWidget extends StatelessWidget {
final Event? event;
final VoidCallback? onDismissNewWordOverlay;
final VoidCallback? onFlagTokenInfo;
// final TokenInfoFeedbackRequestData? requestData;
// final PangeaMessageEvent? pangeaMessageEvent;
final Function(LemmaInfoResponse, String)? onFlagTokenInfo;
const WordZoomWidget({
super.key,
@ -55,6 +52,8 @@ class WordZoomWidget extends StatelessWidget {
final bool? subscribed =
MatrixState.pangeaController.subscriptionController.isSubscribed;
final overlayColor = Theme.of(context).scaffoldBackgroundColor;
final showTranscript =
MatrixState.pangeaController.userController.showTranscription;
final Widget content = subscribed != null && !subscribed
? const MessageUnsubscribedCard()
@ -106,11 +105,14 @@ class WordZoomWidget extends StatelessWidget {
),
),
onFlagTokenInfo != null
? IconButton(
icon: const Icon(Icons.flag_outlined),
onPressed: onFlagTokenInfo,
tooltip:
L10n.of(context).reportWordIssueTooltip,
? TokenFeedbackButton(
textLanguage: PLanguageStore.byLangCode(
langCode,
) ??
LanguageModel.unknown,
constructId: construct,
text: token.content,
onFlagTokenInfo: onFlagTokenInfo!,
)
: const SizedBox(
width: 40.0,
@ -118,17 +120,12 @@ class WordZoomWidget extends StatelessWidget {
),
],
),
LemmaMeaningBuilder(
langCode: langCode,
constructId: construct,
builder: (context, controller) {
return Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
if (MatrixState.pangeaController.userController
.showTranscription)
PhoneticTranscriptionWidget(
Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
showTranscript
? PhoneticTranscriptionWidget(
text: token.content,
textLanguage: PLanguageStore.byLangCode(
langCode,
@ -137,62 +134,23 @@ class WordZoomWidget extends StatelessWidget {
style: const TextStyle(fontSize: 14.0),
iconSize: 24.0,
)
else
WordAudioButton(
: WordAudioButton(
text: token.content,
uniqueID: "lemma-content-${token.content}",
langCode: langCode,
iconSize: 24.0,
),
LemmaReactionPicker(
emojis: controller.lemmaInfo?.emoji ?? [],
loading: controller.isLoading,
event: event,
),
if (controller.error != null)
ErrorIndicator(
message: L10n.of(context)
.errorFetchingDefinition,
style: const TextStyle(fontSize: 14.0),
)
else if (controller.isLoading ||
controller.lemmaInfo == null)
const CircularProgressIndicator.adaptive()
else
construct.lemma.toLowerCase() ==
token.content.toLowerCase()
? Text(
controller.lemmaInfo!.meaning,
style:
const TextStyle(fontSize: 14.0),
textAlign: TextAlign.center,
)
: RichText(
text: TextSpan(
style: DefaultTextStyle.of(context)
.style
.copyWith(
fontSize: 14.0,
),
children: [
TextSpan(text: construct.lemma),
const WidgetSpan(
child: SizedBox(width: 8.0),
),
const TextSpan(text: ":"),
const WidgetSpan(
child: SizedBox(width: 8.0),
),
TextSpan(
text: controller
.lemmaInfo!.meaning,
),
],
),
),
],
);
},
LemmaReactionPicker(
construct: construct,
langCode: langCode,
event: event,
),
LemmaMeaningDisplay(
langCode: langCode,
constructId: construct,
text: token.content,
),
],
),
],
),