feat: allow users to edit morph meanings (#2047)

This commit is contained in:
ggurdin 2025-03-05 14:33:10 -05:00 committed by GitHub
parent 714613f871
commit b8e7eca69c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 340 additions and 40 deletions

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"dart.previewLsp": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "never"
},
"editor.formatOnSave": true
}

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_identifier.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_level_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
@ -102,23 +103,15 @@ class MorphDetailsView extends StatelessWidget {
AsyncSnapshot<String?> snapshot,
) {
if (snapshot.hasData) {
return RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
return MorphMeaningWidget(
feature: _morphFeature,
tag: _morphTag,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
children: <TextSpan>[
TextSpan(
text: L10n.of(context).meaningSectionHeader,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: " ${snapshot.data!}",
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
} else if (snapshot.hasError) {

View file

@ -0,0 +1,227 @@
import 'dart:developer';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart';
class MorphMeaningWidget extends StatefulWidget {
final String feature;
final String tag;
final TextStyle? style;
final InlineSpan? leading;
const MorphMeaningWidget({
super.key,
required this.feature,
required this.tag,
this.style,
this.leading,
});
@override
MorphMeaningWidgetState createState() => MorphMeaningWidgetState();
}
class MorphMeaningWidgetState extends State<MorphMeaningWidget> {
bool _editMode = false;
late TextEditingController _controller;
static const int maxCharacters = 140;
String? _cachedResponse;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<String> _morphMeaning() async {
if (_cachedResponse != null) {
return _cachedResponse!;
}
final response = await MorphInfoRepo.get(
feature: widget.feature,
tag: widget.tag,
);
_cachedResponse = response;
return response ?? L10n.of(context).meaningNotFound;
}
void _toggleEditMode(bool value) => setState(() => _editMode = value);
Future<void> editMorphMeaning(String userEdit) async {
// Truncate to max characters if needed
final truncatedEdit = userEdit.length > maxCharacters
? userEdit.substring(0, maxCharacters)
: userEdit;
await MorphInfoRepo.setMorphDefinition(
feature: widget.feature,
tag: widget.tag,
defintion: truncatedEdit,
);
// Update the cached response
_cachedResponse = truncatedEdit;
_toggleEditMode(false);
}
void _setMeaningText(String initialText) {
_controller.text = initialText.substring(
0,
min(initialText.length, maxCharacters),
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _morphMeaning(),
builder: (context, snapshot) {
if (snapshot.hasData) {
_setMeaningText(snapshot.data!);
}
if (_editMode) {
return MorphEditView(
morphFeature: widget.feature,
morphTag: widget.tag,
meaning: snapshot.data ?? "",
controller: _controller,
toggleEditMode: _toggleEditMode,
editMorphMeaning: editMorphMeaning,
);
}
if (snapshot.connectionState != ConnectionState.done) {
return const TextLoadingShimmer();
}
if (snapshot.hasError || snapshot.data == null) {
debugger(when: kDebugMode);
return Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
style: widget.style,
);
}
return Flexible(
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: () => _toggleEditMode(true),
onDoubleTap: () => _toggleEditMode(true),
child: RichText(
text: TextSpan(
style: widget.style,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) const TextSpan(text: ' '),
TextSpan(text: snapshot.data!),
],
),
),
),
),
);
},
);
}
}
class MorphEditView extends StatelessWidget {
final String morphFeature;
final String morphTag;
final String meaning;
final TextEditingController controller;
final void Function(bool) toggleEditMode;
final void Function(String) editMorphMeaning;
const MorphEditView({
required this.morphFeature,
required this.morphTag,
required this.meaning,
required this.controller,
required this.toggleEditMode,
required this.editMorphMeaning,
super.key,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(
getGrammarCopy(
category: morphFeature,
lemma: morphTag,
context: context,
) ??
morphTag,
'',
)}",
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
minLines: 1,
maxLines: 3,
maxLength: MorphMeaningWidgetState.maxCharacters,
controller: controller,
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => toggleEditMode(false),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).cancel),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () =>
controller.text != meaning && controller.text.isNotEmpty
? editMorphMeaning(controller.text)
: null,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).saveChanges),
),
],
),
],
),
);
}
}

View file

@ -140,26 +140,17 @@ class VocabDetailsView extends StatelessWidget {
alignment: Alignment.topLeft,
child: _userL2 == null
? Text(L10n.of(context).meaningNotFound)
: Wrap(
children: [
Text(
L10n.of(context).meaningSectionHeader,
style:
Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
: LemmaMeaningWidget(
text: _construct.lemma,
pos: _construct.category,
langCode: _userL2!,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
const SizedBox(width: 8),
// Pass the lemma text and form correctly
// The lemma text is in _construct.lemma
// For the form, we use the same value since we don't have access to PangeaToken's form
LemmaMeaningWidget(
text: _construct.lemma,
pos: _construct.category,
langCode: _userL2!,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
),

View file

@ -96,4 +96,33 @@ class MorphInfoRepo {
return res.getFeatureByCode(feature)?.getTagByCode(tag)?.l1Description;
}
static Future<void> setMorphDefinition({
required String feature,
required String tag,
required String defintion,
}) async {
final userL1 =
MatrixState.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage;
final userL2 =
MatrixState.pangeaController.languageController.userL2?.langCode ??
LanguageKeys.defaultLanguage;
final userL1Short = userL1.split('-').first;
final userL2Short = userL2.split('-').first;
final cachedJson = _morphMeaningStorage.read(userL1Short + userL2Short);
MorphInfoResponse? resp = MorphInfoResponse(
userL1: userL1,
userL2: userL2,
features: [],
);
if (cachedJson is Map<String, dynamic>) {
resp = MorphInfoResponse.fromJson(cachedJson);
}
resp.setMorphDefinition(feature, tag, defintion);
await _morphMeaningStorage.write(userL1Short + userL2Short, resp.toJson());
}
}

View file

@ -3,7 +3,7 @@ import 'package:collection/collection.dart';
class MorphologicalTag {
final String code;
final String l1Title;
final String l1Description;
String l1Description;
MorphologicalTag({
required this.code,
@ -103,4 +103,48 @@ class MorphInfoResponse {
(feature) => feature.code.toLowerCase() == code.toLowerCase(),
);
}
void setMorphDefinition(
String morphFeature,
String morphTag,
String defintion,
) {
final featureIndex = features.indexWhere(
(feature) => feature.code.toLowerCase() == morphFeature.toLowerCase(),
);
if (featureIndex == -1) {
features.add(
MorphologicalFeature(
code: morphFeature,
l1Title: morphFeature,
tags: [
MorphologicalTag(
code: morphTag,
l1Title: morphTag,
l1Description: defintion,
),
],
),
);
return;
}
final tagIndex = features[featureIndex].tags.indexWhere(
(tag) => tag.code.toLowerCase() == morphTag.toLowerCase(),
);
if (tagIndex == -1) {
features[featureIndex].tags.add(
MorphologicalTag(
code: morphTag,
l1Title: morphTag,
l1Description: defintion,
),
);
return;
}
features[featureIndex].tags[tagIndex].l1Description = defintion;
}
}

View file

@ -18,6 +18,7 @@ class LemmaMeaningWidget extends StatefulWidget {
final String text;
final String langCode;
final TextStyle? style;
final InlineSpan? leading;
const LemmaMeaningWidget({
super.key,
@ -25,6 +26,7 @@ class LemmaMeaningWidget extends StatefulWidget {
required this.text,
required this.langCode,
this.style,
this.leading,
});
@override
@ -139,10 +141,15 @@ class LemmaMeaningWidgetState extends State<LemmaMeaningWidget> {
child: GestureDetector(
onLongPress: () => _toggleEditMode(true),
onDoubleTap: () => _toggleEditMode(true),
child: Text(
snapshot.data!.meaning,
textAlign: TextAlign.center,
style: widget.style,
child: RichText(
text: TextSpan(
style: widget.style,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) const TextSpan(text: ' '),
TextSpan(text: snapshot.data!.meaning),
],
),
),
),
),