Merge branch 'main' of https://github.com/pangeachat/client into show-disabled-buttons

This commit is contained in:
Kelrap 2025-12-11 12:05:46 -05:00
commit 175c11ea0b
31 changed files with 904 additions and 666 deletions

View file

@ -102,30 +102,47 @@ class SettingsSecurityController extends State<SettingsSecurity> {
if (mxid == null || mxid.isEmpty || mxid != supposedMxid) {
return;
}
final input = await showTextInputDialog(
useRootNavigator: false,
// #Pangea
// final input = await showTextInputDialog(
// useRootNavigator: false,
// context: context,
// title: L10n.of(context).pleaseEnterYourPassword,
// okLabel: L10n.of(context).ok,
// cancelLabel: L10n.of(context).cancel,
// isDestructive: true,
// obscureText: true,
// hintText: '******',
// minLines: 1,
// maxLines: 1,
// );
// if (input == null) return;
// await showFutureLoadingDialog(
// context: context,
// future: () => Matrix.of(context).client.deactivateAccount(
// auth: AuthenticationPassword(
// password: input,
// identifier: AuthenticationUserIdentifier(
// user: Matrix.of(context).client.userID!,
// ),
// ),
// ),
// );
// Pangea#
final resp = await showFutureLoadingDialog(
context: context,
title: L10n.of(context).pleaseEnterYourPassword,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
isDestructive: true,
obscureText: true,
hintText: '******',
minLines: 1,
maxLines: 1,
);
if (input == null) return;
await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.deactivateAccount(
auth: AuthenticationPassword(
password: input,
identifier: AuthenticationUserIdentifier(
user: Matrix.of(context).client.userID!,
delay: false,
future: () =>
Matrix.of(context).client.uiaRequestBackground<IdServerUnbindResult?>(
(auth) => Matrix.of(context).client.deactivateAccount(
auth: auth,
),
),
),
),
);
if (!resp.isError) {
await Matrix.of(context).client.logout();
}
}
void showBootstrapDialog(BuildContext context) async {

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/animated_progress_bar.dart';
class LevelPopupProgressBar extends StatefulWidget {
final double height;
final Duration duration;
const LevelPopupProgressBar({
required this.height,
required this.duration,
super.key,
});
@override
LevelPopupProgressBarState createState() => LevelPopupProgressBarState();
}
class LevelPopupProgressBarState extends State<LevelPopupProgressBar> {
double width = 0.0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
width = 1.0;
});
});
}
@override
Widget build(BuildContext context) {
return AnimatedProgressBar(
height: widget.height,
widthPercent: width,
barColor: AppConfig.goldLight,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
duration: widget.duration,
);
}
}

View file

@ -12,11 +12,10 @@ import 'package:matrix/matrix_api_lite/generated/model.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_popup_progess_bar.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/level_bar.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_details.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
@ -193,11 +192,6 @@ class _LevelUpPopupContentState extends State<LevelUpPopupContent>
@override
@override
Widget build(BuildContext context) {
final Animation<double> progressAnimation =
Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: const Interval(0.0, 0.5)),
);
final Animation<int> vocabAnimation =
IntTween(begin: _startVocab, end: _endVocab).animate(
CurvedAnimation(
@ -282,23 +276,10 @@ class _LevelUpPopupContentState extends State<LevelUpPopupContent>
animation: _controller,
builder: (_, __) => Row(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return LevelBar(
details: const LevelBarDetails(
fillColor: AppConfig.goldLight,
currentPoints: 0,
widthMultiplier: 1,
),
progressBarDetails: ProgressBarDetails(
totalWidth: constraints.maxWidth *
progressAnimation.value,
height: 20,
borderColor: colorScheme.primary,
),
);
},
const Expanded(
child: LevelPopupProgressBar(
height: 20,
duration: Duration(milliseconds: 1000),
),
),
const SizedBox(width: 8),

View file

@ -1,21 +1,20 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_details.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/animated_progress_bar.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LearningProgressBar extends StatelessWidget {
final int level;
final int totalXP;
final double? height;
final double height;
final bool loading;
const LearningProgressBar({
required this.level,
required this.totalXP,
required this.loading,
this.height,
required this.height,
super.key,
});
@ -30,16 +29,12 @@ class LearningProgressBar extends StatelessWidget {
),
);
}
return ProgressBar(
return AnimatedProgressBar(
height: height,
levelBars: [
LevelBarDetails(
fillColor: Theme.of(context).colorScheme.primary,
currentPoints: totalXP,
widthMultiplier:
MatrixState.pangeaController.getAnalytics.levelProgress,
),
],
widthPercent: MatrixState.pangeaController.getAnalytics.levelProgress,
barColor: AppConfig.goldLight,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
);
}
}

View file

@ -1,105 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
class AnimatedLevelBar extends StatefulWidget {
final double height;
final double beginWidth;
final double endWidth;
final Color primaryColor;
const AnimatedLevelBar({
super.key,
required this.height,
required this.beginWidth,
required this.endWidth,
required this.primaryColor,
});
@override
AnimatedLevelBarState createState() => AnimatedLevelBarState();
}
class AnimatedLevelBarState extends State<AnimatedLevelBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
double get _beginWidth =>
widget.beginWidth == 0 ? 0 : max(20, widget.beginWidth);
double get _endWidth => widget.endWidth == 0 ? 0 : max(20, widget.endWidth);
/// 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 == 0 ? 0 : max(20, oldWidget.endWidth)) !=
(widget.endWidth == 0 ? 0 : max(20, 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: _endWidth,
end: _endWidth,
).animate(_controller);
}
// animate the width of the bar
return Tween<double>(
begin: _beginWidth,
end: _endWidth,
).animate(_controller);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Stack(
children: [
Container(
height: widget.height,
width: _animation.value,
decoration: BoxDecoration(
color: widget.primaryColor,
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
),
),
],
);
},
);
}
}

View file

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
class AnimatedProgressBar extends StatelessWidget {
final double height;
final double widthPercent;
final Color barColor;
final Color backgroundColor;
final Duration? duration;
const AnimatedProgressBar({
required this.height,
required this.widthPercent,
required this.barColor,
required this.backgroundColor,
this.duration,
super.key,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
alignment: Alignment.centerLeft,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
height: height,
width: constraints.maxWidth,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
color: backgroundColor,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: AnimatedContainer(
duration: duration ?? FluffyThemes.animationDuration,
height: height,
width: constraints.maxWidth * widthPercent,
decoration: BoxDecoration(
color: barColor,
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
),
),
),
],
);
},
);
}
}

View file

@ -1,43 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/animated_level_dart.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_details.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 =>
widget.progressBarDetails.totalWidth * widget.details.widthMultiplier;
@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,
primaryColor: AppConfig.goldLight,
);
}
}

View file

@ -1,64 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/level_bar.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_background.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_details.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 StatefulWidget {
final List<LevelBarDetails> levelBars;
final double? height;
const ProgressBar({
super.key,
required this.levelBars,
this.height,
});
@override
ProgressBarState createState() => ProgressBarState();
}
class ProgressBarState extends State<ProgressBar> {
double width = 0;
void setWidth(double newWidth) {
if (width != newWidth) {
setState(() => width = newWidth);
}
}
get progressBarDetails => ProgressBarDetails(
totalWidth: width,
borderColor: Theme.of(context).colorScheme.secondaryContainer,
height: widget.height ?? 14,
);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (width != constraints.maxWidth) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => setWidth(constraints.maxWidth),
);
}
return Stack(
alignment: Alignment.centerLeft,
children: [
ProgressBarBackground(details: progressBarDetails),
for (final levelBar in widget.levelBars)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: LevelBar(
details: levelBar,
progressBarDetails: progressBarDetails,
),
),
],
);
},
);
}
}

View file

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

View file

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

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,
),
],
),
],
),