From ba4d05c8af615998efd3e23ce5e1ef8c916769a2 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 15 Dec 2025 13:49:32 -0500 Subject: [PATCH] chore: update morph meaning repo --- .../morph_meaning_widget.dart | 92 +++---- .../morphs/morph_meaning/morph_info_repo.dart | 248 +++++++++++------- 2 files changed, 198 insertions(+), 142 deletions(-) diff --git a/lib/pangea/analytics_details_popup/morph_meaning_widget.dart b/lib/pangea/analytics_details_popup/morph_meaning_widget.dart index d97a2b15d..fd34f5c9d 100644 --- a/lib/pangea/analytics_details_popup/morph_meaning_widget.dart +++ b/lib/pangea/analytics_details_popup/morph_meaning_widget.dart @@ -4,11 +4,12 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; -import 'package:fluffychat/pangea/common/network/requests.dart'; -import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/languages/language_constants.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart'; +import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_request.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class MorphMeaningWidget extends StatefulWidget { @@ -29,16 +30,16 @@ class MorphMeaningWidget extends StatefulWidget { class MorphMeaningWidgetState extends State { bool _editMode = false; + late TextEditingController _controller; static const int maxCharacters = 140; - String? _cachedResponse; + + String? _definition; bool _isLoading = true; - Object? _error; @override void didUpdateWidget(covariant MorphMeaningWidget oldWidget) { if (oldWidget.tag != widget.tag || oldWidget.feature != widget.feature) { - _cachedResponse = null; _isLoading = true; _loadMorphMeaning(); } @@ -58,28 +59,44 @@ class MorphMeaningWidgetState extends State { super.dispose(); } + MorphInfoRequest get _request => MorphInfoRequest( + userL1: MatrixState.pangeaController.userController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + userL2: MatrixState.pangeaController.userController.userL2?.langCode ?? + LanguageKeys.defaultLanguage, + ); + Future _loadMorphMeaning() async { - try { - final response = await _morphMeaning(); - _setMeaningText(response); - } catch (e) { - _error = e; - } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) { + setState(() { + _isLoading = true; + _definition = null; + }); } + + final response = await _morphMeaning(); + _controller.text = response.substring( + 0, + min(response.length, maxCharacters), + ); + _definition = response; + + if (mounted) setState(() => _isLoading = false); } Future _morphMeaning() async { - if (_cachedResponse != null) { - return _cachedResponse!; + final result = await MorphInfoRepo.get( + MatrixState.pangeaController.userController.accessToken, + _request, + ); + + if (result.isError) { + return L10n.of(context).meaningNotFound; } - final response = await MorphInfoRepo.get( - feature: widget.feature, - tag: widget.tag, - ); - _cachedResponse = response; - return response ?? L10n.of(context).meaningNotFound; + final morph = result.result!.getFeatureByCode(widget.feature.name); + final data = morph?.getTagByCode(widget.tag); + return data?.l1Description ?? L10n.of(context).meaningNotFound; } void _toggleEditMode(bool value) => setState(() => _editMode = value); @@ -90,22 +107,15 @@ class MorphMeaningWidgetState extends State { ? userEdit.substring(0, maxCharacters) : userEdit; - await MorphInfoRepo.setMorphDefinition( + await MorphInfoRepo.update( + _request, feature: widget.feature, tag: widget.tag, - defintion: truncatedEdit, + definition: truncatedEdit, ); - // Update the cached response - _cachedResponse = truncatedEdit; _toggleEditMode(false); - } - - void _setMeaningText(String initialText) { - _controller.text = initialText.substring( - 0, - min(initialText.length, maxCharacters), - ); + _loadMorphMeaning(); } @override @@ -114,27 +124,11 @@ class MorphMeaningWidgetState extends State { return const TextLoadingShimmer(); } - if (_error != null) { - return Center( - child: _error is UnsubscribedException - ? ErrorIndicator( - message: L10n.of(context).subscribeToUnlockDefinitions, - onTap: () { - MatrixState.pangeaController.subscriptionController - .showPaywall(context); - }, - ) - : ErrorIndicator( - message: L10n.of(context).errorFetchingDefinition, - ), - ); - } - if (_editMode) { return MorphEditView( morphFeature: widget.feature, morphTag: widget.tag, - meaning: _cachedResponse ?? "", + meaning: _definition ?? "", controller: _controller, toggleEditMode: _toggleEditMode, editMorphMeaning: editMorphMeaning, @@ -149,7 +143,7 @@ class MorphMeaningWidgetState extends State { onDoubleTap: () => _toggleEditMode(true), child: Text( textAlign: TextAlign.center, - _cachedResponse ?? L10n.of(context).meaningNotFound, + _definition ?? L10n.of(context).meaningNotFound, style: widget.style, ), ), diff --git a/lib/pangea/morphs/morph_meaning/morph_info_repo.dart b/lib/pangea/morphs/morph_meaning/morph_info_repo.dart index 3a0a0d900..684c3134d 100644 --- a/lib/pangea/morphs/morph_meaning/morph_info_repo.dart +++ b/lib/pangea/morphs/morph_meaning/morph_info_repo.dart @@ -1,130 +1,192 @@ import 'dart:convert'; +import 'dart:io'; -import 'package:flutter/foundation.dart'; - +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/languages/language_constants.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_request.dart'; import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_response.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; -class _APICallCacheItem { - final DateTime time; - final Future future; +class _MorphInfoCacheItem { + final Future> resultFuture; + final DateTime timestamp; - _APICallCacheItem(this.time, this.future); + const _MorphInfoCacheItem({ + required this.resultFuture, + required this.timestamp, + }); } class MorphInfoRepo { - static final GetStorage _morphMeaningStorage = - GetStorage('morph_meaning_storage'); - static final shortTermCache = {}; - static const int _cacheDurationMinutes = 1; + // In-memory cache + static final Map _cache = {}; + static const Duration _cacheDuration = Duration(minutes: 10); - static void set(MorphInfoRequest request, MorphInfoResponse response) { - _morphMeaningStorage.write(request.storageKey, response.toJson()); - } +// Persistent storage + static final GetStorage _storage = GetStorage('morph_info_storage'); - static Future _fetch(MorphInfoRequest request) async { - try { - final Requests req = Requests( - choreoApiKey: Environment.choreoApiKey, - accessToken: MatrixState.pangeaController.userController.accessToken, - ); - - final Response res = await req.post( - url: PApiUrls.morphDictionary, - body: request.toJson(), - ); - - final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); - final response = MorphInfoResponse.fromJson(decodedBody); - - set(request, response); - - return response; - } catch (e) { - debugPrint('Error fetching morph info: $e'); - return Future.error(e); - } - } - - static Future _get(MorphInfoRequest request) async { - request.userL1 == request.userL1.split('-').first; - request.userL2 == request.userL2.split('-').first; - - final cachedJson = _morphMeaningStorage.read(request.storageKey); - if (cachedJson != null) { - return MorphInfoResponse.fromJson(cachedJson); + static Future> get( + String accessToken, + MorphInfoRequest request, + ) { + // 1. Try memory cache + final cached = _getCached(request); + if (cached != null) { + return cached; } - final _APICallCacheItem? cachedCall = shortTermCache[request.storageKey]; - if (cachedCall != null) { - if (DateTime.now().difference(cachedCall.time).inMinutes < - _cacheDurationMinutes) { - return cachedCall.future; - } else { - shortTermCache.remove(request.storageKey); - } + // 2. Try disk cache + final stored = _getStored(request); + if (stored != null) { + return Future.value(Result.value(stored)); } - final future = _fetch(request); - shortTermCache[request.storageKey] = - _APICallCacheItem(DateTime.now(), future); + // 3. Fetch from network (safe future) + final future = _safeFetch(accessToken, request); + + // 4. Save to in-memory cache + _cache[request.hashCode.toString()] = _MorphInfoCacheItem( + resultFuture: future, + timestamp: DateTime.now(), + ); + + // 5. Write to disk *after* the fetch finishes, without rethrowing + writeToDisk(request, future); + return future; } - static Future get({ - required MorphFeaturesEnum feature, - required String tag, - }) async { - final res = await _get( - MorphInfoRequest( - userL1: MatrixState.pangeaController.userController.userL1?.langCode ?? - LanguageKeys.defaultLanguage, - userL2: MatrixState.pangeaController.userController.userL2?.langCode ?? - LanguageKeys.defaultLanguage, - ), - ); - final morph = res.getFeatureByCode(feature.name); - - final data = morph?.getTagByCode(tag); - - return data?.l1Description; + static Future set( + MorphInfoRequest request, + MorphInfoResponse 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: {'request': request.toJson()}, + ); + } } - static Future setMorphDefinition({ + static Future update( + MorphInfoRequest request, { required MorphFeaturesEnum feature, required String tag, - required String defintion, + required String definition, }) async { - final userL1 = - MatrixState.pangeaController.userController.userL1?.langCode ?? - LanguageKeys.defaultLanguage; - final userL2 = - MatrixState.pangeaController.userController.userL2?.langCode ?? - LanguageKeys.defaultLanguage; - final userL1Short = userL1.split('-').first; - final userL2Short = userL2.split('-').first; - final cachedJson = _morphMeaningStorage.read(userL1Short + userL2Short); + try { + final cachedJson = await _getCached(request); + final resp = cachedJson?.result ?? + MorphInfoResponse( + userL1: request.userL1, + userL2: request.userL2, + features: [], + ); - MorphInfoResponse? resp = MorphInfoResponse( - userL1: userL1, - userL2: userL2, - features: [], + resp.setMorphDefinition(feature.name, tag, definition); + await set(request, resp); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'request': request.toJson()}, + ); + } + } + + static Future> _safeFetch( + String token, + MorphInfoRequest 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 _fetch( + String accessToken, + MorphInfoRequest request, + ) async { + final req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: accessToken, ); - if (cachedJson is Map) { - resp = MorphInfoResponse.fromJson(cachedJson); + final Response res = await req.post( + url: PApiUrls.morphDictionary, + body: request.toJson(), + ); + + if (res.statusCode != 200) { + throw HttpException( + 'Failed to fetch morph info: ${res.statusCode} ${res.reasonPhrase}', + ); } - resp.setMorphDefinition(feature.name, tag, defintion); - await _morphMeaningStorage.write(userL1Short + userL2Short, resp.toJson()); + return MorphInfoResponse.fromJson( + jsonDecode(utf8.decode(res.bodyBytes)), + ); + } + + static Future>? _getCached( + MorphInfoRequest 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 writeToDisk( + MorphInfoRequest request, + Future> resultFuture, + ) async { + final result = await resultFuture; // SAFE: never throws + + if (!result.isValue) return; // only cache successful responses + await set(request, result.asValue!.value); + } + + static MorphInfoResponse? _getStored( + MorphInfoRequest request, + ) { + final key = request.hashCode.toString(); + try { + final entry = _storage.read(key); + if (entry == null) return null; + + return MorphInfoResponse.fromJson(entry); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'request': request.toJson()}, + ); + _storage.remove(key); + return null; + } } }