Merge branch 'main' into 2576-level-up-ui-tweaks

This commit is contained in:
ggurdin 2025-06-23 11:05:43 -04:00
commit b15aec1f04
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
18 changed files with 657 additions and 666 deletions

View file

@ -78,6 +78,10 @@ abstract class AppConfig {
static const Color silver = Color.fromARGB(255, 192, 192, 192);
static const Color bronze = Color.fromARGB(255, 205, 127, 50);
static const Color goldLight = Color.fromARGB(255, 254, 223, 73);
static const Color yellowLight = Color.fromARGB(255, 247, 218, 120);
static const Color yellowDark = Color.fromARGB(255, 253, 191, 1);
static const Color error = Colors.red;
static const int overlayAnimationDuration = 250;
static const int roomCreationTimeoutSeconds = 15;

View file

@ -37,7 +37,6 @@ class AudioPlayerWidget extends StatefulWidget {
final PangeaAudioFile? matrixFile;
final ChatController chatController;
final MessageOverlayController? overlayController;
final VoidCallback? onPlay;
final bool autoplay;
// Pangea#
@ -55,7 +54,6 @@ class AudioPlayerWidget extends StatefulWidget {
this.matrixFile,
required this.chatController,
this.overlayController,
this.onPlay,
this.autoplay = false,
// Pangea#
super.key,
@ -465,11 +463,7 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
onLongPress: () =>
widget.event?.saveFile(context),
// Pangea#
onTap: () {
widget.onPlay != null
? widget.onPlay!.call()
: _onButtonTap();
},
onTap: _onButtonTap,
child: Material(
color: widget.color.withAlpha(64),
borderRadius: BorderRadius.circular(64),

View file

@ -225,14 +225,6 @@ class MessageContent extends StatelessWidget {
eventId: event.eventId,
roomId: event.room.id,
senderId: event.senderId,
onPlay: overlayController == null
? () {
controller.showToolbar(
pangeaMessageEvent!.event,
pangeaMessageEvent: pangeaMessageEvent,
);
}
: null,
autoplay: overlayController != null,
// Pangea#
);

View file

@ -28,9 +28,6 @@ class VocabDetailsView extends StatelessWidget {
ConstructUses get _construct => constructId.constructUses;
String? get _userL1 =>
MatrixState.pangeaController.languageController.userL1?.langCode;
/// Get the language code for the current lemma
String? get _userL2 =>
MatrixState.pangeaController.languageController.userL2?.langCode;
@ -135,8 +132,6 @@ class VocabDetailsView extends StatelessWidget {
: LemmaMeaningWidget(
constructUse: _construct,
langCode: _userL2!,
controller: null,
token: null,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,

View file

@ -544,7 +544,7 @@ class RoomDetailsButtonRowState extends State<RoomDetailsButtonRow> {
);
}
final button = buttons[index];
final button = mainViewButtons[index];
return Expanded(
child: RoomDetailsButton(
mini: mini,
@ -729,66 +729,70 @@ class RoomParticipantsSection extends StatelessWidget {
padding: EdgeInsets.all(_padding),
child: SizedBox(
width: _width,
child: Column(
children: [
Stack(
alignment: Alignment.center,
children: [
if (gradient != null)
CircleAvatar(
radius: _width / 2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: gradient,
),
),
)
else
SizedBox(
height: _width,
width: _width,
),
Builder(
builder: (context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => showMemberActionsPopupMenu(
context: context,
user: user,
child: Opacity(
opacity: user.membership == Membership.join ? 1.0 : 0.5,
child: Column(
children: [
Stack(
alignment: Alignment.center,
children: [
if (gradient != null)
CircleAvatar(
radius: _width / 2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: gradient,
),
child: Center(
child: Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
size: _width - 6.0,
presenceUserId: user.id,
showPresence: false,
),
)
else
SizedBox(
height: _width,
width: _width,
),
Builder(
builder: (context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => showMemberActionsPopupMenu(
context: context,
user: user,
),
child: Center(
child: Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
size: _width - 6.0,
presenceUserId: user.id,
showPresence: false,
),
),
),
),
);
},
),
],
),
Text(
user.calcDisplayname(),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
);
},
),
overflow: TextOverflow.ellipsis,
),
LevelDisplayName(
userId: user.id,
textStyle: Theme.of(context).textTheme.labelSmall,
),
],
],
),
Text(
user.calcDisplayname(),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color:
Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
LevelDisplayName(
userId: user.id,
textStyle: Theme.of(context).textTheme.labelSmall,
),
],
),
),
),
);

View file

@ -256,14 +256,7 @@ class IgcController {
timeline: choreographer.chatController.timeline!,
ownMessage: event.senderId ==
choreographer.pangeaController.matrixState.client.userID,
)
.getSpeechToTextLocal(
choreographer.l1LangCode,
choreographer.l2LangCode,
)
?.transcript
.text
.trim(); // trim whitespace
).getSpeechToTextLocal()?.transcript.text.trim(); // trim whitespace
if (content == null) continue;
messages.add(
PreviousMessage(

View file

@ -232,13 +232,7 @@ class PangeaMessageEvent {
null;
}).toSet();
SpeechToTextModel? getSpeechToTextLocal(
String? l1Code,
String? l2Code,
) {
if (l1Code == null || l2Code == null) {
return null;
}
SpeechToTextModel? getSpeechToTextLocal() {
return representations
.firstWhereOrNull(
(element) => element.content.speechToText != null,

View file

@ -53,7 +53,7 @@ class LemmaReactionPickerState extends State<LemmaReactionPicker> {
} catch (e, s) {
ErrorHandler.logError(data: widget.cId.toJson(), e: e, s: s);
} finally {
setState(() => loading = false);
if (mounted) setState(() => loading = false);
}
}

View file

@ -1,29 +1,85 @@
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
class TokenPositionModel {
/// Start index of the full substring in the message
final int start;
/// End index of the full substring in the message
final int end;
/// Start index of the token in the message
final int tokenStart;
/// End index of the token in the message
final int tokenEnd;
final bool selected;
final bool hideContent;
class TokenPosition {
final PangeaToken? token;
final int startIndex;
final int endIndex;
const TokenPositionModel({
required this.start,
required this.end,
required this.tokenStart,
required this.tokenEnd,
required this.hideContent,
required this.selected,
const TokenPosition({
this.token,
required this.startIndex,
required this.endIndex,
});
}
class TokensUtil {
static List<TokenPosition> getTokenPositions(
List<PangeaToken> tokens,
) {
final List<TokenPosition> tokenPositions = [];
int tokenPointer = 0;
int globalPointer = 0;
while (tokenPointer < tokens.length) {
int endIndex = tokenPointer;
PangeaToken token = tokens[tokenPointer];
if (token.text.offset > globalPointer) {
// If the token starts after the current global pointer, we need to
// create a new token position for the gap
tokenPositions.add(
TokenPosition(
startIndex: globalPointer,
endIndex: token.text.offset,
),
);
globalPointer = token.text.offset;
}
// move the end index if the next token is right next to the current token
// and either the current token is punctuation or the next token is punctuation
while (endIndex < tokens.length - 1) {
final PangeaToken currentToken = tokens[endIndex];
final PangeaToken nextToken = tokens[endIndex + 1];
final currentIsPunct = currentToken.pos == 'PUNCT' &&
currentToken.text.content.trim().isNotEmpty;
final nextIsPunct = nextToken.pos == 'PUNCT' &&
nextToken.text.content.trim().isNotEmpty;
if (currentToken.text.offset + currentToken.text.length !=
nextToken.text.offset) {
break;
}
if ((currentIsPunct && nextIsPunct) ||
(currentIsPunct && nextToken.text.content.trim().isNotEmpty) ||
(nextIsPunct && currentToken.text.content.trim().isNotEmpty)) {
if (token.pos == 'PUNCT' && !nextIsPunct) {
token = nextToken;
}
endIndex++;
} else {
break;
}
}
tokenPositions.add(
TokenPosition(
token: token,
startIndex: tokens[tokenPointer].text.offset,
endIndex: tokens[endIndex].text.offset + tokens[endIndex].text.length,
),
);
// Move to the next token
tokenPointer = tokenPointer + (endIndex - tokenPointer) + 1;
globalPointer =
tokens[endIndex].text.offset + tokens[endIndex].text.length;
}
return tokenPositions;
}
}

View file

@ -89,6 +89,14 @@ class LoadParticipantsUtilState extends State<LoadParticipantsUtil> {
return -1;
}
if (a.membership != Membership.join && b.membership != Membership.join) {
return a.displayName?.compareTo(b.displayName ?? '') ?? 0;
} else if (a.membership != Membership.join) {
return 1;
} else if (b.membership != Membership.join) {
return -1;
}
final PublicProfileModel? aProfile = _levelsCache[a.id];
final PublicProfileModel? bProfile = _levelsCache[b.id];
@ -100,7 +108,7 @@ class LoadParticipantsUtilState extends State<LoadParticipantsUtil> {
Future<void> _cacheLevels() async {
for (final user in participants) {
if (_levelsCache[user.id] == null) {
if (_levelsCache[user.id] == null && user.membership == Membership.join) {
_levelsCache[user.id] = await MatrixState
.pangeaController.userController
.getPublicProfile(user.id);

View file

@ -461,9 +461,18 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
pangeaMessageEvent?.messageDisplayLangCode.split("-")[0] ==
MatrixState.pangeaController.languageController.userL2?.langCodeShort;
PangeaToken? get selectedToken =>
pangeaMessageEvent?.messageDisplayRepresentation?.tokens
?.firstWhereOrNull(isTokenSelected);
PangeaToken? get selectedToken {
if (pangeaMessageEvent?.isAudioMessage == true) {
final stt = pangeaMessageEvent!.getSpeechToTextLocal();
if (stt == null || stt.transcript.sttTokens.isEmpty) return null;
return stt.transcript.sttTokens
.firstWhereOrNull((t) => isTokenSelected(t.token))
?.token;
}
return pangeaMessageEvent?.messageDisplayRepresentation?.tokens
?.firstWhereOrNull(isTokenSelected);
}
/// Whether the overlay is currently displaying a selection
bool get isSelection => _selectedSpan != null || _highlightedTokens != null;

View file

@ -15,6 +15,7 @@ import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/stt_transcript_tokens.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/file_description.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -177,15 +178,17 @@ class OverlayMessage extends StatelessWidget {
spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
overlayController
.transcription!.transcript.text,
SttTranscriptTokens(
model: overlayController.transcription!,
style: AppConfig.messageTextStyle(
event,
textColor,
).copyWith(
fontStyle: FontStyle.italic,
),
onClick: overlayController
.onClickOverlayMessageToken,
isSelected: overlayController.isTokenSelected,
),
if (MatrixState.pangeaController
.languageController.showTrancription)

View file

@ -13,6 +13,7 @@ class WordAudioButton extends StatefulWidget {
final String uniqueID;
final String langCode;
final EdgeInsets? padding;
final double? iconSize;
/// If defined, this callback will be called instead of the default one
final void Function()? callbackOverride;
@ -26,6 +27,7 @@ class WordAudioButton extends StatefulWidget {
this.baseOpacity = 1,
this.callbackOverride,
this.padding,
this.iconSize,
});
@override
@ -118,6 +120,7 @@ class WordAudioButtonState extends State<WordAudioButton> {
color: _isPlaying
? Theme.of(context).colorScheme.primary
: null,
size: widget.iconSize,
),
),
),

View file

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/message_token_text/token_position_model.dart';
import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SttTranscriptTokens extends StatelessWidget {
final SpeechToTextModel model;
final TextStyle? style;
final void Function(PangeaToken)? onClick;
final bool Function(PangeaToken)? isSelected;
const SttTranscriptTokens({
super.key,
required this.model,
this.onClick,
this.isSelected,
this.style,
});
List<PangeaToken> get tokens =>
model.transcript.sttTokens.map((t) => t.token).toList();
@override
Widget build(BuildContext context) {
if (model.transcript.sttTokens.isEmpty) {
return Text(
model.transcript.text,
style: style ?? DefaultTextStyle.of(context).style,
textScaler: TextScaler.noScaling,
);
}
final messageCharacters = model.transcript.text.characters;
return RichText(
textScaler: TextScaler.noScaling,
text: TextSpan(
style: style ?? DefaultTextStyle.of(context).style,
children: TokensUtil.getTokenPositions(tokens).map((tokenPosition) {
final text = messageCharacters
.skip(tokenPosition.startIndex)
.take(tokenPosition.endIndex - tokenPosition.startIndex)
.toString();
if (tokenPosition.token == null) {
return TextSpan(
text: text,
style: style ?? DefaultTextStyle.of(context).style,
);
}
final token = tokenPosition.token!;
final selected = isSelected?.call(token) ?? false;
return WidgetSpan(
child: CompositedTransformTarget(
link: MatrixState.pAnyState
.layerLinkAndKey(token.text.uniqueKey)
.link,
child: MouseRegion(
key: MatrixState.pAnyState
.layerLinkAndKey(token.text.uniqueKey)
.key,
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onClick != null ? () => onClick?.call(token) : null,
child: RichText(
text: TextSpan(
text: text,
style: (style ?? DefaultTextStyle.of(context).style)
.copyWith(
decoration: TextDecoration.underline,
decorationThickness: 4,
decorationColor: selected
? Theme.of(context).colorScheme.primary
: Colors.white.withAlpha(0),
),
),
),
),
),
),
);
}).toList(),
),
);
}
}

View file

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.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 LemmaMeaningBuilder extends StatefulWidget {
final String langCode;
final ConstructIdentifier constructId;
final Widget Function(
BuildContext context,
LemmaMeaningBuilderState controller,
) builder;
const LemmaMeaningBuilder({
super.key,
required this.langCode,
required this.constructId,
required this.builder,
});
@override
LemmaMeaningBuilderState createState() => LemmaMeaningBuilderState();
}
class LemmaMeaningBuilderState extends State<LemmaMeaningBuilder> {
bool editMode = false;
LemmaInfoResponse? lemmaInfo;
bool isLoading = true;
String? error;
TextEditingController controller = TextEditingController();
@override
void initState() {
super.initState();
_fetchLemmaMeaning();
}
@override
void didUpdateWidget(covariant LemmaMeaningBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructId != widget.constructId ||
oldWidget.langCode != widget.langCode) {
_fetchLemmaMeaning();
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
LemmaInfoRequest get _request => LemmaInfoRequest(
lemma: widget.constructId.lemma,
partOfSpeech: widget.constructId.category,
lemmaLang: widget.langCode,
userL1:
MatrixState.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
);
Future<void> _fetchLemmaMeaning() async {
setState(() {
isLoading = true;
error = null;
});
try {
final resp = await LemmaInfoRepo.get(_request);
lemmaInfo = resp;
controller.text = resp.meaning;
} catch (e) {
error = e.toString();
} finally {
if (mounted) setState(() => isLoading = false);
}
}
void toggleEditMode(bool value) => setState(() => editMode = value);
Future<void> editLemmaMeaning(String userEdit) async {
final originalMeaning = lemmaInfo;
if (originalMeaning != null) {
LemmaInfoRepo.set(
_request,
LemmaInfoResponse(emoji: originalMeaning.emoji, meaning: userEdit),
);
toggleEditMode(false);
_fetchLemmaMeaning();
}
}
@override
Widget build(BuildContext context) {
return widget.builder(
context,
this,
);
}
}

View file

@ -3,250 +3,138 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart';
class LemmaMeaningWidget extends StatefulWidget {
class LemmaMeaningWidget extends StatelessWidget {
final ConstructUses constructUse;
final String langCode;
final TextStyle? style;
final InlineSpan? leading;
/// These are not present if this widget is used outside the chat
/// (e.g. in the vocab details view)
/// TODO: let the user assign the meaning in the vocab details view
final MessageOverlayController? controller;
final PangeaToken? token;
const LemmaMeaningWidget({
super.key,
required this.constructUse,
required this.langCode,
required this.controller,
required this.token,
this.style,
this.leading,
});
@override
LemmaMeaningWidgetState createState() => LemmaMeaningWidgetState();
}
class LemmaMeaningWidgetState extends State<LemmaMeaningWidget> {
bool _editMode = false;
late TextEditingController _controller;
LemmaInfoResponse? _lemmaInfo;
bool _isLoading = true;
String? _error;
String get _lemma => widget.constructUse.lemma;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_fetchLemmaMeaning();
}
@override
void didUpdateWidget(covariant LemmaMeaningWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructUse != widget.constructUse ||
oldWidget.langCode != widget.langCode) {
_fetchLemmaMeaning();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
LemmaInfoRequest get _request => LemmaInfoRequest(
lemma: _lemma,
partOfSpeech: widget.constructUse.category,
/// This assumes that the user's L2 is the language of the lemma
lemmaLang: widget.langCode,
userL1:
MatrixState.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
);
Future<void> _fetchLemmaMeaning() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
_lemmaInfo = await LemmaInfoRepo.get(_request);
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _toggleEditMode(bool value) => setState(() => _editMode = value);
Future<void> editLemmaMeaning(String userEdit) async {
final originalMeaning = _lemmaInfo;
if (originalMeaning != null) {
LemmaInfoRepo.set(
_request,
LemmaInfoResponse(emoji: originalMeaning.emoji, meaning: userEdit),
);
_toggleEditMode(false);
_fetchLemmaMeaning();
}
}
@override
Widget build(BuildContext context) {
if (widget.token != null &&
widget.controller?.practiceSelection != null &&
widget.controller!.practiceSelection!.hasActiveActivityByToken(
ActivityTypeEnum.wordMeaning,
widget.token!,
) &&
widget.controller!.readingAssistanceMode ==
ReadingAssistanceMode.practiceMode) {
return WordZoomActivityButton(
icon: const Icon(Symbols.dictionary),
isSelected: widget.controller?.toolbarMode == MessageMode.wordMeaning,
onPressed: widget.controller != null
? () {
// TODO: it would be better to explicitly set to wordMeaningChoice here
widget.controller!.updateToolbarMode(MessageMode.wordMeaning);
}
: () => {},
opacity:
widget.controller?.toolbarMode == MessageMode.wordMeaning ? 1 : 0.4,
);
}
return LemmaMeaningBuilder(
langCode: langCode,
constructId: constructUse.id,
builder: (context, controller) {
if (controller.isLoading) {
return const TextLoadingShimmer();
}
if (_isLoading) {
return const TextLoadingShimmer();
}
if (controller.error != null) {
debugger(when: kDebugMode);
return Text(
L10n.of(context).oopsSomethingWentWrong,
textAlign: TextAlign.center,
);
}
if (_error != null) {
debugger(when: kDebugMode);
return Text(
L10n.of(context).oopsSomethingWentWrong,
textAlign: TextAlign.center,
);
}
if (_editMode) {
_controller.text = _lemmaInfo?.meaning ?? "";
return Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(_lemma, widget.constructUse.category)}",
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,
controller: _controller,
decoration: InputDecoration(
hintText: _lemmaInfo?.meaning,
),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
if (controller.editMode) {
controller.controller.text = controller.lemmaInfo?.meaning ?? "";
return Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
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),
Text(
"${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(
constructUse.lemma,
constructUse.category,
)}",
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () => _controller.text != _lemmaInfo?.meaning &&
_controller.text.isNotEmpty
? editLemmaMeaning(_controller.text)
: null,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
minLines: 1,
maxLines: 3,
controller: controller.controller,
decoration: InputDecoration(
hintText: controller.lemmaInfo?.meaning,
),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).saveChanges),
),
],
),
],
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: () => _toggleEditMode(true),
onDoubleTap: () => _toggleEditMode(true),
child: RichText(
textAlign:
widget.leading == null ? TextAlign.center : TextAlign.start,
text: TextSpan(
style: widget.style?.copyWith(
color: widget.controller?.toolbarMode ==
MessageMode.wordMeaning
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null)
const WidgetSpan(child: SizedBox(width: 6.0)),
TextSpan(
text: _lemmaInfo?.meaning,
ElevatedButton(
onPressed: () => controller.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.controller.text !=
controller.lemmaInfo?.meaning &&
controller.controller.text.isNotEmpty
? controller
.editLemmaMeaning(controller.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),
),
],
),
],
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: () => controller.toggleEditMode(true),
onDoubleTap: () => controller.toggleEditMode(true),
child: RichText(
textAlign:
leading == null ? TextAlign.center : TextAlign.start,
text: TextSpan(
style: style,
children: [
if (leading != null) leading!,
if (leading != null)
const WidgetSpan(child: SizedBox(width: 6.0)),
TextSpan(
text: controller.lemmaInfo?.meaning,
),
],
),
),
),
),
),
),
),
],
],
);
},
);
}
}

View file

@ -1,191 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class LemmaWidget extends StatefulWidget {
final PangeaToken token;
final PangeaMessageEvent pangeaMessageEvent;
final VoidCallback onEdit;
final VoidCallback onEditDone;
final MessageOverlayController? overlayController;
const LemmaWidget({
super.key,
required this.token,
required this.pangeaMessageEvent,
required this.onEdit,
required this.onEditDone,
required this.overlayController,
});
@override
LemmaWidgetState createState() => LemmaWidgetState();
}
class LemmaWidgetState extends State<LemmaWidget> {
bool _editMode = false;
late TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggleEditMode(bool value) {
value ? widget.onEdit() : widget.onEditDone();
setState(() => _editMode = value);
}
Future<void> _editLemma() async {
try {
final existingTokens = widget.pangeaMessageEvent.originalSent!.tokens!
.map((token) => PangeaToken.fromJson(token.toJson()))
.toList();
// change the morphological tag in the selected token
final tokenIndex = existingTokens.indexWhere(
(token) => token.text.offset == widget.token.text.offset,
);
if (tokenIndex == -1) {
throw Exception("Token not found in message");
}
existingTokens[tokenIndex].lemma.text = _controller.text;
await widget.pangeaMessageEvent.room.pangeaSendTextEvent(
widget.pangeaMessageEvent.messageDisplayText,
editEventId: widget.pangeaMessageEvent.eventId,
originalSent: widget.pangeaMessageEvent.originalSent?.content,
originalWritten: widget.pangeaMessageEvent.originalWritten?.content,
tokensSent: PangeaMessageTokens(
tokens: existingTokens,
detections: widget.pangeaMessageEvent.originalSent!.detections,
),
tokensWritten: widget.pangeaMessageEvent.originalWritten?.tokens != null
? PangeaMessageTokens(
tokens: widget.pangeaMessageEvent.originalWritten!.tokens!,
detections:
widget.pangeaMessageEvent.originalWritten?.detections,
)
: null,
choreo: widget.pangeaMessageEvent.originalSent?.choreo,
messageTag: ModelKey.messageTagLemmaEdit,
);
_toggleEditMode(false);
} catch (e) {
SnackBar(
content: Text(L10n.of(context).oopsSomethingWentWrong),
);
ErrorHandler.logError(
e: e,
data: {
"token": widget.token.toJson(),
"pangeaMessageEvent": widget.pangeaMessageEvent.event.content,
},
);
}
}
@override
Widget build(BuildContext context) {
if (_editMode) {
_controller.text = widget.token.lemma.text;
return Material(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
spacing: 10.0,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsLemma}",
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
TextField(
minLines: 1,
maxLines: 3,
controller: _controller,
),
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 != widget.token.lemma.text
? showFutureLoadingDialog(
context: context,
future: () async => _editLemma(),
)
: null;
},
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).saveChanges),
),
],
),
],
),
),
);
}
return Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
widget.token.lemma.text,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
),
),
WordAudioButton(
text: widget.token.lemma.text,
baseOpacity: 0.4,
uniqueID: "lemma-content-${widget.token.text.content}",
langCode: widget.pangeaMessageEvent.messageDisplayLangCode,
padding: const EdgeInsets.all(4.0),
),
],
),
);
}
}

View file

@ -1,17 +1,20 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart';
import 'package:fluffychat/pangea/lemmas/lemma_reaction_picker.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_widget.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
class WordZoomWidget extends StatelessWidget {
@ -28,8 +31,6 @@ class WordZoomWidget extends StatelessWidget {
PangeaToken get _selectedToken => overlayController.selectedToken!;
void _onEditDone() => overlayController.initializeTokensAndMode();
bool get hasEmojiActivity =>
overlayController.practiceSelection?.hasActiveActivityByToken(
ActivityTypeEnum.emoji,
@ -40,159 +41,199 @@ class WordZoomWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ConstrainedBox(
return Container(
padding: const EdgeInsets.all(12.0),
constraints: const BoxConstraints(
minHeight: AppConfig.toolbarMinHeight,
maxHeight: AppConfig.toolbarMaxHeight,
minHeight: AppConfig.toolbarMinHeight - 8,
maxHeight: AppConfig.toolbarMaxHeight - 8,
maxWidth: AppConfig.toolbarMinWidth,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
Container(
constraints: const BoxConstraints(
minHeight: 40,
),
color: Theme.of(context).colorScheme.surface,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
//@ggurdin - might need to play with size to properly center
SizedBox(
width: 24.0,
height: 24.0,
child: IconButton(
onPressed: () => overlayController.updateSelectedSpan(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 24.0,
height: 24.0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => overlayController.updateSelectedSpan(
token.text,
),
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
child: const Icon(
Icons.close,
size: 16.0,
),
),
),
LemmaWidget(
token: _selectedToken,
pangeaMessageEvent: messageEvent,
// onEdit: () => _setHideCenterContent(true),
onEdit: () {
debugPrint("what are we doing edits with?");
},
onEditDone: () {
debugPrint("what are we doing edits with?");
_onEditDone();
},
overlayController: overlayController,
),
Text(
token.text.content,
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w600,
height: 1.2,
color: Theme.of(context).brightness == Brightness.light
? AppConfig.yellowDark
: AppConfig.yellowLight,
),
ConstructXpWidget(
id: token.vocabConstructID,
onTap: () => showDialog<AnalyticsPopupWrapper>(
context: context,
builder: (context) => AnalyticsPopupWrapper(
constructZoom: token.vocabConstructID,
view: ConstructTypeEnum.vocab,
),
),
ConstructXpWidget(
id: token.vocabConstructID,
onTap: () => showDialog<AnalyticsPopupWrapper>(
context: context,
builder: (context) => AnalyticsPopupWrapper(
constructZoom: token.vocabConstructID,
view: ConstructTypeEnum.vocab,
),
),
],
),
),
],
),
const SizedBox(
height: 8.0,
LemmaMeaningBuilder(
langCode: messageEvent.messageDisplayLangCode,
constructId: token.vocabConstructID,
builder: (context, controller) {
if (controller.editMode) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(
token.vocabConstructID.lemma,
token.vocabConstructID.category,
)}",
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,
controller: controller.controller,
decoration: InputDecoration(
hintText: controller.lemmaInfo?.meaning,
),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => controller.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.controller.text !=
controller.lemmaInfo?.meaning &&
controller.controller.text.isNotEmpty
? controller.editLemmaMeaning(
controller.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),
),
],
),
],
);
}
return Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
if (MatrixState
.pangeaController.languageController.showTrancription)
PhoneticTranscriptionWidget(
text: token.text.content,
textLanguage: PLanguageStore.byLangCode(
messageEvent.messageDisplayLangCode,
) ??
LanguageModel.unknown,
style: const TextStyle(fontSize: 14.0),
iconSize: 24.0,
)
else
WordAudioButton(
text: token.text.content,
uniqueID: "lemma-content-${token.text.content}",
langCode: messageEvent.messageDisplayLangCode,
iconSize: 24.0,
),
LemmaReactionPicker(
cId: _selectedToken.vocabConstructID,
controller: overlayController.widget.chatController,
),
if (controller.error != null)
Text(
L10n.of(context).oopsSomethingWentWrong,
textAlign: TextAlign.center,
)
else if (controller.isLoading ||
controller.lemmaInfo == null)
const CircularProgressIndicator.adaptive()
else
GestureDetector(
onLongPress: () => controller.toggleEditMode(true),
onDoubleTap: () => controller.toggleEditMode(true),
child: token.lemma.text == token.text.content
? 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: token.lemma.text),
const WidgetSpan(
child: SizedBox(width: 8.0),
),
const TextSpan(text: ":"),
const WidgetSpan(
child: SizedBox(width: 8.0),
),
TextSpan(
text: controller.lemmaInfo!.meaning,
),
],
),
),
),
],
);
},
),
LemmaReactionPicker(
cId: _selectedToken.vocabConstructID,
controller: overlayController.widget.chatController,
),
// Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Container(
// constraints: const BoxConstraints(
// minHeight: 40,
// ),
// alignment: Alignment.center,
// child: LemmaEmojiRow(
// cId: _selectedToken.vocabConstructID,
// onTapOverride: overlayController.hideWordCardContent &&
// hasEmojiActivity
// ? () => overlayController.updateToolbarMode(
// MessageMode.wordEmoji,
// )
// : null,
// isSelected:
// overlayController.toolbarMode == MessageMode.wordEmoji,
// emojiSetCallback: () => overlayController.setState(() {}),
// shouldShowEmojis: !hasEmojiActivity,
// ),
// ),
// ],
// ),
const SizedBox(
height: 8.0,
),
Container(
constraints: const BoxConstraints(
minHeight: 40,
),
alignment: Alignment.center,
child: Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
LemmaMeaningWidget(
constructUse: token.vocabConstructID.constructUses,
langCode: MatrixState.pangeaController.languageController
.userL2?.langCodeShort ??
LanguageKeys.defaultLanguage,
token: overlayController.selectedToken!,
controller: overlayController,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
// const SizedBox(
// height: 8.0,
// ),
// Wrap(
// alignment: WrapAlignment.center,
// crossAxisAlignment: WrapCrossAlignment.center,
// spacing: 8.0,
// children: [
// if (token.text.content.toLowerCase() !=
// token.lemma.text.toLowerCase()) ...[
// Text(
// _selectedToken.text.content,
// style: Theme.of(context).textTheme.bodyLarge,
// overflow: TextOverflow.ellipsis,
// ),
// WordAudioButton(
// text: _selectedToken.text.content,
// baseOpacity: 0.4,
// uniqueID: "word-zoom-audio-${_selectedToken.text.content}",
// langCode: overlayController
// .pangeaMessageEvent!.messageDisplayLangCode,
// ),
// ],
// ..._selectedToken.morphsBasicallyEligibleForPracticeByPriority
// .map(
// (cId) => MorphologicalListItem(
// morphFeature: MorphFeaturesEnumExtension.fromString(
// cId.category,
// ),
// token: _selectedToken,
// overlayController: overlayController,
// ),
// ),
// ],
// ),
],
),
),