full refactor of all chore-related controllers

This commit is contained in:
ggurdin 2025-10-29 17:07:15 -04:00
parent 749517fafb
commit d945959ba0
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
37 changed files with 1564 additions and 1818 deletions

View file

@ -40,12 +40,16 @@ import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat/utils/unlocked_morphs_snackbar.dart';
import 'package:fluffychat/pangea/chat/widgets/event_too_large_dialog.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart';
import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart';
import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart';
import 'package:fluffychat/pangea/choreographer/repo/language_mismatch_repo.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/language_mismatch_popup.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/message_analytics_feedback.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
@ -371,7 +375,7 @@ class ChatController extends State<ChatPageWithRoom>
if (evt is KeyDownEvent) {
// #Pangea
// send();
choreographer.send(context);
onInputBarSubmitted('');
// Pangea#
}
return KeyEventResult.handled;
@ -776,7 +780,6 @@ class ChatController extends State<ChatPageWithRoom>
inputFocus.removeListener(_inputFocusListener);
onFocusSub?.cancel();
//#Pangea
choreographer.stateStream.close();
choreographer.dispose();
MatrixState.pAnyState.closeAllOverlays(force: true);
showToolbarStream.close();
@ -1694,7 +1697,7 @@ class ChatController extends State<ChatPageWithRoom>
void onSelectMessage(Event event) {
// #Pangea
if (choreographer.itController.willOpen) {
if (choreographer.isITOpen) {
return;
}
// Pangea#
@ -1741,10 +1744,22 @@ class ChatController extends State<ChatPageWithRoom>
}
// #Pangea
void onInputBarSubmitted(String _, BuildContext context) {
// void onInputBarSubmitted(_) {
// void onInputBarSubmitted(String _) {
Future<void> onInputBarSubmitted(String _) async {
// send();
choreographer.send(context);
try {
await choreographer.send();
} on ShowPaywallException {
PaywallCard.show(context, choreographer.inputTransformTargetKey);
} on OpenMatchesException {
if (choreographer.firstIGCMatch != null) {
OverlayUtil.showIGCMatch(
choreographer.firstIGCMatch!,
choreographer,
context,
);
}
}
// Pangea#
FocusScope.of(context).requestFocus(inputFocus);
}
@ -2193,11 +2208,14 @@ class ChatController extends State<ChatPageWithRoom>
context: context,
cardToShow: LanguageMismatchPopup(
targetLanguage: targetLanguage,
choreographer: choreographer,
onUpdate: () async {
await choreographer.getLanguageHelp(manual: true);
if (choreographer.igc.canShowFirstMatch) {
choreographer.igc.showFirstMatch(context);
final igcMatch = await choreographer.requestLanguageAssistance();
if (igcMatch != null) {
OverlayUtil.showIGCMatch(
igcMatch,
choreographer,
context,
);
}
},
),

View file

@ -278,11 +278,7 @@ class ChatInputRow extends StatelessWidget {
AppConfig.sendOnEnter == true && PlatformInfos.isMobile
? TextInputAction.send
: null,
// #Pangea
// onSubmitted: controller.onInputBarSubmitted,
onSubmitted: (_) =>
controller.onInputBarSubmitted(_, context),
// Pangea#
onSubmitted: controller.onInputBarSubmitted,
onSubmitImage: controller.sendImageFromClipBoard,
focusNode: controller.inputFocus,
controller: controller.sendController,

View file

@ -8,7 +8,12 @@ import 'package:matrix/matrix.dart';
import 'package:slugify/slugify.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/toolbar/utils/shrinkable_text.dart';
import 'package:fluffychat/utils/markdown_context_builder.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
@ -421,6 +426,41 @@ class InputBar extends StatelessWidget {
}
}
// #Pangea
void onInputTap(BuildContext context, {required FocusNode fNode}) {
fNode.requestFocus();
// show the paywall if appropriate
final choreographer = controller!.choreographer;
if (MatrixState
.pangeaController.subscriptionController.subscriptionStatus ==
SubscriptionStatus.shouldShowPaywall &&
controller!.text.isNotEmpty) {
PaywallCard.show(context, choreographer.inputTransformTargetKey);
return;
}
// if there is no igc text data, then don't do anything
if (!choreographer.hasIGCTextData) return;
final selection = controller!.selection;
if (selection.baseOffset >= controller!.text.length) {
return;
}
final match = choreographer.getMatchByOffset(
selection.baseOffset,
);
if (match == null) return;
// if autoplay on and it start then just start it
if (match.updatedMatch.isITStart) {
return choreographer.openIT(match);
}
OverlayUtil.showIGCMatch(match, choreographer, context);
}
// Pangea#
@override
Widget build(BuildContext context) {
// #Pangea
@ -486,7 +526,7 @@ class InputBar extends StatelessWidget {
? const TextStyle(color: Colors.red)
: null,
onTap: () {
controller?.onInputTap(
onInputTap(
context,
fNode: focusNode,
);

View file

@ -1,14 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
class ActivityRoleTooltip extends StatefulWidget {
class ActivityRoleTooltip extends StatelessWidget {
final Choreographer choreographer;
const ActivityRoleTooltip({
@ -16,48 +15,32 @@ class ActivityRoleTooltip extends StatefulWidget {
super.key,
});
@override
State<ActivityRoleTooltip> createState() => ActivityRoleTooltipState();
}
class ActivityRoleTooltipState extends State<ActivityRoleTooltip> {
Room get room => widget.choreographer.chatController.room;
StreamSubscription? _choreoSub;
@override
void initState() {
super.initState();
_choreoSub = widget.choreographer.stateStream.stream.listen((event) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
}
Room get room => choreographer.chatController.room;
@override
Widget build(BuildContext context) {
if (!room.showActivityChatUI ||
room.ownRole?.goal == null ||
widget.choreographer.itController.willOpen) {
return const SizedBox();
}
return ListenableBuilder(
listenable: choreographer,
builder: (context, _) {
if (!room.showActivityChatUI ||
room.ownRole?.goal == null ||
choreographer.isITOpen) {
return const SizedBox();
}
return InlineTooltip(
message: room.ownRole!.goal!,
isClosed: room.hasDismissedGoalTooltip,
onClose: () async {
await room.dismissGoalTooltip();
if (mounted) setState(() {});
return InlineTooltip(
message: room.ownRole!.goal!,
isClosed: room.hasDismissedGoalTooltip,
onClose: () async {
await room.dismissGoalTooltip();
},
padding: const EdgeInsets.only(
left: 16.0,
top: 16.0,
right: 16.0,
),
);
},
padding: const EdgeInsets.only(
left: 16.0,
top: 16.0,
right: 16.0,
),
);
}
}

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/widgets/has_error_button.dart';
import 'package:fluffychat/pangea/choreographer/widgets/language_permissions_warning_buttons.dart';
import 'package:fluffychat/pangea/spaces/models/space_model.dart';
@ -36,7 +37,6 @@ class ChatFloatingActionButtonState extends State<ChatFloatingActionButton> {
widget.controller.room,
);
showPermissionsError = !itEnabled || !igcEnabled;
debugPrint("showPermissionsError: $showPermissionsError");
if (showPermissionsError) {
Future.delayed(
@ -46,12 +46,6 @@ class ChatFloatingActionButtonState extends State<ChatFloatingActionButton> {
},
);
}
// Rebuild the widget each time there's an update from choreo (i.e., an error).
_choreoSub = widget.controller.choreographer.stateStream.stream.listen((_) {
setState(() {});
});
super.initState();
}
@ -74,19 +68,25 @@ class ChatFloatingActionButtonState extends State<ChatFloatingActionButton> {
child: const Icon(Icons.arrow_downward_outlined),
);
}
if (widget.controller.choreographer.errorService.error != null &&
!widget.controller.choreographer.itController.willOpen) {
return ChoreographerHasErrorButton(
widget.controller.choreographer.errorService.error!,
widget.controller.choreographer,
);
}
return showPermissionsError
? LanguagePermissionsButtons(
choreographer: widget.controller.choreographer,
roomID: widget.controller.roomId,
)
: const SizedBox.shrink();
return ListenableBuilder(
listenable: widget.controller.choreographer,
builder: (context, _) {
if (widget.controller.choreographer.errorService.error != null &&
!widget.controller.choreographer.isITOpen) {
return ChoreographerHasErrorButton(
widget.controller.choreographer.errorService.error!,
widget.controller.choreographer,
);
}
return showPermissionsError
? LanguagePermissionsButtons(
choreographer: widget.controller.choreographer,
roomID: widget.controller.roomId,
)
: const SizedBox.shrink();
},
);
}
}

View file

@ -1,58 +1,41 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
class ChatViewBackground extends StatefulWidget {
class ChatViewBackground extends StatelessWidget {
final Choreographer choreographer;
const ChatViewBackground(this.choreographer, {super.key});
@override
ChatViewBackgroundState createState() => ChatViewBackgroundState();
}
class ChatViewBackgroundState extends State<ChatViewBackground> {
StreamSubscription? _choreoSub;
@override
void initState() {
// Rebuild the widget each time there's an update from choreo
_choreoSub = widget.choreographer.stateStream.stream.listen((_) {
setState(() {});
});
super.initState();
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.choreographer.itController.willOpen
? Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: Material(
borderOnForeground: false,
color: const Color.fromRGBO(0, 0, 0, 1).withAlpha(150),
clipBehavior: Clip.antiAlias,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5),
child: Container(
height: double.infinity,
width: double.infinity,
color: Colors.transparent,
return ListenableBuilder(
listenable: choreographer,
builder: (context, _) {
return choreographer.isITOpen
? Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: Material(
borderOnForeground: false,
color: const Color.fromRGBO(0, 0, 0, 1).withAlpha(150),
clipBehavior: Clip.antiAlias,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5),
child: Container(
height: double.infinity,
width: double.infinity,
color: Colors.transparent,
),
),
),
),
),
)
: const SizedBox.shrink();
)
: const SizedBox.shrink();
},
);
}
}

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
@ -9,13 +7,15 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/input_bar.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/widgets/send_button.dart';
import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/utils/platform_infos.dart';
class PangeaChatInputRow extends StatefulWidget {
class PangeaChatInputRow extends StatelessWidget {
final ChatController controller;
const PangeaChatInputRow({
@ -23,37 +23,13 @@ class PangeaChatInputRow extends StatefulWidget {
super.key,
});
@override
State<PangeaChatInputRow> createState() => PangeaChatInputRowState();
}
class PangeaChatInputRowState extends State<PangeaChatInputRow> {
StreamSubscription? _choreoSub;
@override
void initState() {
// Rebuild the widget each time there's an update from choreo
_choreoSub = widget.controller.choreographer.stateStream.stream.listen((_) {
setState(() {});
});
super.initState();
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
}
ChatController get _controller => widget.controller;
LanguageModel? get activel1 =>
_controller.pangeaController.languageController.activeL1Model();
controller.pangeaController.languageController.activeL1Model();
LanguageModel? get activel2 =>
_controller.pangeaController.languageController.activeL2Model();
controller.pangeaController.languageController.activeL2Model();
String hintText() {
if (_controller.choreographer.itController.willOpen) {
String hintText(BuildContext context) {
if (controller.choreographer.isITOpen) {
return L10n.of(context).buildTranslation;
}
return activel1 != null &&
@ -72,202 +48,207 @@ class PangeaChatInputRowState extends State<PangeaChatInputRow> {
final theme = Theme.of(context);
const height = 48.0;
if (widget.controller.selectMode) {
if (controller.selectMode) {
return const SizedBox(height: height);
}
return Column(
children: [
CompositedTransformTarget(
link: _controller.choreographer.inputLayerLinkAndKey.link,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(8.0),
return ListenableBuilder(
listenable: controller.choreographer,
builder: (context, _) {
return Column(
children: [
CompositedTransformTarget(
link: controller.choreographer.inputLayerLinkAndKey.link,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(8.0),
),
),
child: Row(
key: controller.choreographer.inputLayerLinkAndKey.key,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const SizedBox(width: 4),
AnimatedContainer(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
height: height,
width:
controller.sendController.text.isEmpty ? height : 0,
alignment: Alignment.center,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: PopupMenuButton<String>(
useRootNavigator: true,
icon: const Icon(Icons.add_outlined),
onSelected: controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'file',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
child: Icon(Icons.attachment_outlined),
),
title: Text(L10n.of(context).sendFile),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'image',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
child: Icon(Icons.image_outlined),
),
title: Text(L10n.of(context).sendImage),
contentPadding: const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
child: Icon(Icons.camera_alt_outlined),
),
title: Text(L10n.of(context).openCamera),
contentPadding: const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera-video',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child: Icon(Icons.videocam_outlined),
),
title: Text(L10n.of(context).openVideoCamera),
contentPadding: const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'location',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.brown,
foregroundColor: Colors.white,
child: Icon(Icons.gps_fixed_outlined),
),
title: Text(L10n.of(context).shareLocation),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
),
if (FluffyThemes.isColumnMode(context))
Container(
height: height,
width: height,
alignment: Alignment.center,
child: IconButton(
tooltip: L10n.of(context).emojis,
icon: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.scaled,
fillColor: Colors.transparent,
child: child,
);
},
child: Icon(
controller.showEmojiPicker
? Icons.keyboard
: Icons.add_reaction_outlined,
key: ValueKey(controller.showEmojiPicker),
),
),
onPressed: controller.emojiPickerAction,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0.0),
child: InputBar(
room: controller.room,
minLines: 1,
maxLines: 8,
autofocus: !PlatformInfos.isMobile,
keyboardType: TextInputType.multiline,
textInputAction: AppConfig.sendOnEnter == true &&
PlatformInfos.isMobile
? TextInputAction.send
: null,
onSubmitted: controller.onInputBarSubmitted,
onSubmitImage: controller.sendImageFromClipBoard,
focusNode: controller.inputFocus,
controller: controller.sendController,
decoration: const InputDecoration(
contentPadding: EdgeInsets.only(
left: 6.0,
right: 6.0,
bottom: 6.0,
top: 3.0,
),
disabledBorder: InputBorder.none,
hintMaxLines: 1,
border: InputBorder.none,
enabledBorder: InputBorder.none,
filled: false,
),
onChanged: controller.onInputBarChanged,
hintText: hintText(context),
),
),
),
StartIGCButton(
controller: controller,
),
Container(
height: height,
width: height,
alignment: Alignment.center,
child: PlatformInfos.platformCanRecord &&
controller.sendController.text.isEmpty &&
!controller.choreographer.isITOpen
? FloatingActionButton.small(
tooltip: L10n.of(context).voiceMessage,
onPressed: controller.voiceMessageAction,
elevation: 0,
heroTag: null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(height),
),
backgroundColor: theme.bubbleColor,
foregroundColor: theme.onBubbleColor,
child: const Icon(Icons.mic_none_outlined),
)
: ChoreographerSendButton(controller: controller),
),
],
),
),
),
child: Row(
key: _controller.choreographer.inputLayerLinkAndKey.key,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const SizedBox(width: 4),
AnimatedContainer(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
height: height,
width: _controller.sendController.text.isEmpty ? height : 0,
alignment: Alignment.center,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: PopupMenuButton<String>(
useRootNavigator: true,
icon: const Icon(Icons.add_outlined),
onSelected: _controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'file',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
child: Icon(Icons.attachment_outlined),
),
title: Text(L10n.of(context).sendFile),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'image',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
child: Icon(Icons.image_outlined),
),
title: Text(L10n.of(context).sendImage),
contentPadding: const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
child: Icon(Icons.camera_alt_outlined),
),
title: Text(L10n.of(context).openCamera),
contentPadding: const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera-video',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child: Icon(Icons.videocam_outlined),
),
title: Text(L10n.of(context).openVideoCamera),
contentPadding: const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'location',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.brown,
foregroundColor: Colors.white,
child: Icon(Icons.gps_fixed_outlined),
),
title: Text(L10n.of(context).shareLocation),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
),
if (FluffyThemes.isColumnMode(context))
Container(
height: height,
width: height,
alignment: Alignment.center,
child: IconButton(
tooltip: L10n.of(context).emojis,
icon: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.scaled,
fillColor: Colors.transparent,
child: child,
);
},
child: Icon(
_controller.showEmojiPicker
? Icons.keyboard
: Icons.add_reaction_outlined,
key: ValueKey(_controller.showEmojiPicker),
),
),
onPressed: _controller.emojiPickerAction,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0.0),
child: InputBar(
room: _controller.room,
minLines: 1,
maxLines: 8,
autofocus: !PlatformInfos.isMobile,
keyboardType: TextInputType.multiline,
textInputAction: AppConfig.sendOnEnter == true &&
PlatformInfos.isMobile
? TextInputAction.send
: null,
onSubmitted: (String value) =>
_controller.onInputBarSubmitted(value, context),
onSubmitImage: _controller.sendImageFromClipBoard,
focusNode: _controller.inputFocus,
controller: _controller.sendController,
decoration: const InputDecoration(
contentPadding: EdgeInsets.only(
left: 6.0,
right: 6.0,
bottom: 6.0,
top: 3.0,
),
disabledBorder: InputBorder.none,
hintMaxLines: 1,
border: InputBorder.none,
enabledBorder: InputBorder.none,
filled: false,
),
onChanged: _controller.onInputBarChanged,
hintText: hintText(),
),
),
),
StartIGCButton(
controller: _controller,
),
Container(
height: height,
width: height,
alignment: Alignment.center,
child: PlatformInfos.platformCanRecord &&
_controller.sendController.text.isEmpty &&
!_controller.choreographer.itController.willOpen
? FloatingActionButton.small(
tooltip: L10n.of(context).voiceMessage,
onPressed: _controller.voiceMessageAction,
elevation: 0,
heroTag: null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(height),
),
backgroundColor: theme.bubbleColor,
foregroundColor: theme.onBubbleColor,
child: const Icon(Icons.mic_none_outlined),
)
: ChoreographerSendButton(controller: _controller),
),
],
),
),
),
],
],
);
},
);
}
}

View file

@ -8,4 +8,6 @@ class ChoreoConstants {
static const green = Colors.green;
static const yellow = Color.fromARGB(255, 206, 152, 2);
static const red = Colors.red;
static const int msBeforeIGCStart = 10000;
static const int maxLength = 1000;
}

View file

@ -1,99 +1,180 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/igc_controller.dart';
import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart';
import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart';
import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart';
import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart';
import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart';
import 'package:fluffychat/pangea/choreographer/models/it_step.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart';
import 'package:fluffychat/pangea/choreographer/utils/input_paste_listener.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/any_state_holder.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/events/repo/token_api_models.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/spaces/models/space_model.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import '../../../widgets/matrix.dart';
import 'error_service.dart';
import 'it_controller.dart';
enum ChoreoMode { igc, it }
class OpenMatchesException implements Exception {}
class Choreographer {
PangeaController pangeaController;
ChatController chatController;
late PangeaTextController _textController;
class ShowPaywallException implements Exception {}
class Choreographer extends ChangeNotifier {
final PangeaController pangeaController;
final ChatController chatController;
late PangeaTextController textController;
late ITController itController;
late IgcController igc;
late ErrorService errorService;
bool isFetching = false;
ChoreoRecord? _choreoRecord;
bool _isFetching = false;
int _timesClicked = 0;
final int msBeforeIGCStart = 10000;
Timer? debounceTimer;
ChoreoRecord? choreoRecord;
// last checked by IGC or translation
Timer? _debounceTimer;
String? _lastChecked;
ChoreoMode choreoMode = ChoreoMode.igc;
ChoreoMode _choreoMode = ChoreoMode.igc;
String? _sourceText;
final StreamController stateStream = StreamController.broadcast();
StreamSubscription? _languageStream;
StreamSubscription? _settingsUpdateStream;
late AssistanceState _currentAssistanceState;
String? translatedText;
Choreographer(this.pangeaController, this.chatController) {
_initialize();
}
_initialize() {
_textController = PangeaTextController(choreographer: this);
InputPasteListener(_textController, onPaste);
int get timesClicked => _timesClicked;
bool get isFetching => _isFetching;
ChoreoMode get choreoMode => _choreoMode;
String? get sourceText => _sourceText;
String get currentText => textController.text;
void _initialize() {
textController = PangeaTextController(choreographer: this);
itController = ITController(this);
igc = IgcController(this);
errorService = ErrorService(this);
_textController.addListener(_onChangeListener);
errorService = ErrorService();
errorService.addListener(notifyListeners);
textController.addListener(_onChange);
_languageStream =
pangeaController.userController.languageStream.stream.listen((update) {
clear();
setState();
notifyListeners();
});
_settingsUpdateStream =
pangeaController.userController.settingsUpdateStream.stream.listen((_) {
setState();
notifyListeners();
});
_currentAssistanceState = assistanceState;
clear();
}
void send(BuildContext context) {
debugPrint("can send message: $canSendMessage");
void clear() {
_choreoMode = ChoreoMode.igc;
_lastChecked = null;
_timesClicked = 0;
_isFetching = false;
_choreoRecord = null;
_sourceText = null;
itController.clear();
igc.clear();
_resetDebounceTimer();
}
@override
void dispose() {
super.dispose();
errorService.dispose();
textController.dispose();
_languageStream?.cancel();
_settingsUpdateStream?.cancel();
TtsController.stop();
}
void onPaste(value) {
_initChoreoRecord();
_choreoRecord!.pastedStrings.add(value);
}
void onClickSend() {
if (assistanceState == AssistanceState.fetched) {
_timesClicked++;
// if user is doing IT, call closeIT here to
// ensure source text is replaced when needed
if (isITOpen && _timesClicked > 1) {
closeIT();
}
}
}
void setChoreoMode(ChoreoMode mode) {
_choreoMode = mode;
notifyListeners();
}
void _resetDebounceTimer() {
if (_debounceTimer != null) {
_debounceTimer?.cancel();
_debounceTimer = null;
}
}
void _initChoreoRecord() {
_choreoRecord ??= ChoreoRecord(
originalText: textController.text,
choreoSteps: [],
openMatches: [],
);
}
void _startLoading() {
_lastChecked = textController.text;
_isFetching = true;
notifyListeners();
}
void _stopLoading() {
_isFetching = false;
notifyListeners();
}
Future<PangeaMatchState?> requestLanguageAssistance() async {
await _getLanguageAssistance(manual: true);
if (igc.canShowFirstMatch) {
return igc.onShowFirstMatch();
}
return null;
}
Future<void> send() async {
// if isFetching, already called to getLanguageHelp and hasn't completed yet
// could happen if user clicked send button multiple times in a row
if (isFetching) return;
if (_isFetching) return;
if (igc.canShowFirstMatch) {
igc.showFirstMatch(context);
return;
throw OpenMatchesException();
} else if (isRunningIT) {
// If the user is in the middle of IT, don't send the message.
// If they've already clicked the send button once, this will
@ -101,16 +182,14 @@ class Choreographer {
return;
}
final isSubscribed = pangeaController.subscriptionController.isSubscribed;
if (isSubscribed != null && !isSubscribed) {
// don't want to run IGC if user isn't subscribed, so either
// show the paywall if applicable or just send the message
final status = pangeaController.subscriptionController.subscriptionStatus;
status == SubscriptionStatus.shouldShowPaywall
? PaywallCard.show(context, chatController)
: chatController.send(
message: chatController.sendController.text,
);
final subscriptionStatus =
pangeaController.subscriptionController.subscriptionStatus;
if (subscriptionStatus != SubscriptionStatus.subscribed) {
if (subscriptionStatus == SubscriptionStatus.shouldShowPaywall) {
throw ShowPaywallException();
}
chatController.send(message: chatController.sendController.text);
return;
}
@ -119,23 +198,79 @@ class Choreographer {
return;
}
if (!igc.hasRelevantIGCTextData && !itController.dismissed) {
getLanguageHelp().then((value) => _sendWithIGC(context));
if (!igc.hasIGCTextData && !itController.dismissed) {
await _getLanguageAssistance();
await send();
} else {
_sendWithIGC(context);
_sendWithIGC();
}
}
Future<void> _sendWithIGC(BuildContext context) async {
if (!canSendMessage) {
// It's possible that the reason user can't send message is because they're in the middle of IT. If this is the case,
// do nothing (there's no matches to show). The user can click the send button again to override this.
if (!isRunningIT) {
igc.showFirstMatch(context);
}
/// Handles any changes to the text input
void _onChange() {
if (_lastChecked != null && _lastChecked == textController.text) {
return;
}
_lastChecked = textController.text;
if (textController.editType == EditType.igc ||
textController.editType == EditType.itDismissed) {
textController.editType = EditType.keyboard;
return;
}
// Close any open IGC/IT overlays
MatrixState.pAnyState.closeOverlay();
if (errorService.isError) return;
igc.clear();
_resetDebounceTimer();
if (textController.editType == EditType.it) {
_getLanguageAssistance();
} else {
_sourceText = null;
_debounceTimer ??= Timer(
const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart),
() => _getLanguageAssistance(),
);
}
//Note: we don't set the keyboard type on each keyboard stroke so this is how we default to
//a change being from the keyboard unless explicitly set to one of the other
//types when that action happens (e.g. an it/igc choice is selected)
textController.editType = EditType.keyboard;
}
/// Fetches the language help for the current text, including grammar correction, language detection,
/// tokens, and translations. Includes logic to exit the flow if the user is not subscribed, if the tools are not enabled, or
/// or if autoIGC is not enabled and the user has not manually requested it.
/// [onlyTokensAndLanguageDetection] will
Future<void> _getLanguageAssistance({
bool manual = false,
}) async {
if (errorService.isError) return;
final SubscriptionStatus canSendStatus =
pangeaController.subscriptionController.subscriptionStatus;
if (canSendStatus != SubscriptionStatus.subscribed ||
l2Lang == null ||
l1Lang == null ||
(!igcEnabled && !itEnabled) ||
(!isAutoIGCEnabled && !manual && _choreoMode != ChoreoMode.it)) {
return;
}
_resetDebounceTimer();
_initChoreoRecord();
_startLoading();
await (isRunningIT ? itController.continueIT() : igc.getIGCTextData());
_stopLoading();
}
Future<void> _sendWithIGC() async {
if (chatController.sendController.text.trim().isEmpty) {
return;
}
@ -143,10 +278,10 @@ class Choreographer {
final message = chatController.sendController.text;
final fakeEventId = chatController.sendFakeMessage();
final PangeaRepresentation? originalWritten =
choreoRecord?.includedIT == true && translatedText != null
_choreoRecord?.includedIT == true && _sourceText != null
? PangeaRepresentation(
langCode: l1LangCode ?? LanguageKeys.unknownLanguage,
text: translatedText!,
text: _sourceText!,
originalWritten: true,
originalSent: false,
)
@ -192,7 +327,7 @@ class Choreographer {
"currentText": message,
"l1LangCode": l1LangCode,
"l2LangCode": l2LangCode,
"choreoRecord": choreoRecord?.toJson(),
"choreoRecord": _choreoRecord?.toJson(),
},
level: e is TimeoutException ? SentryLevel.warning : SentryLevel.error,
);
@ -202,522 +337,174 @@ class Choreographer {
originalSent: originalSent,
originalWritten: originalWritten,
tokensSent: tokensSent,
choreo: choreoRecord,
choreo: _choreoRecord,
tempEventId: fakeEventId,
);
clear();
}
}
_resetDebounceTimer() {
if (debounceTimer != null) {
debounceTimer?.cancel();
debounceTimer = null;
}
}
void _initChoreoRecord() {
choreoRecord ??= ChoreoRecord(
originalText: textController.text,
choreoSteps: [],
openMatches: [],
);
}
void onITStart(PangeaMatchState itMatch) {
void openIT(PangeaMatchState itMatch) {
if (!itMatch.updatedMatch.isITStart) {
throw Exception("this isn't an itStart match!");
throw Exception("Attempted to open IT with a non-IT start match");
}
choreoMode = ChoreoMode.it;
itController.initializeIT(
ITStartData(_textController.text, null),
);
translatedText = _textController.text;
_choreoMode = ChoreoMode.it;
_sourceText = textController.text;
itController.openIT();
igc.clear();
_textController.setSystemText("", EditType.itStart);
textController.setSystemText("", EditType.it);
_initChoreoRecord();
itMatch.setStatus(PangeaMatchStatus.accepted);
choreoRecord!.addRecord(
_textController.text,
_choreoRecord!.addRecord(
textController.text,
match: itMatch.updatedMatch,
);
notifyListeners();
}
/// Handles any changes to the text input
_onChangeListener() {
// Rebuild the IGC button if the state has changed.
// This accounts for user typing after initial IGC has completed
if (_currentAssistanceState != assistanceState) {
setState();
}
if (_noChange) {
return;
}
_lastChecked = _textController.text;
if (_textController.editType == EditType.igc ||
_textController.editType == EditType.itDismissed) {
_textController.editType = EditType.keyboard;
return;
}
// not sure if this is necessary now
MatrixState.pAnyState.closeOverlay();
if (errorService.isError) {
return;
}
igc.clear();
_resetDebounceTimer();
// we store translated text in the choreographer to save at the original written
// text, but if the user edits the text after the translation, reset it, since the
// sent text may not be an exact translation of the original text
if (_textController.editType == EditType.keyboard) {
translatedText = null;
}
if (editTypeIsKeyboard) {
debounceTimer ??= Timer(
Duration(milliseconds: msBeforeIGCStart),
() => getLanguageHelp(),
);
} else {
getLanguageHelp();
}
//Note: we don't set the keyboard type on each keyboard stroke so this is how we default to
//a change being from the keyboard unless explicitly set to one of the other
//types when that action happens (e.g. an it/igc choice is selected)
textController.editType = EditType.keyboard;
void closeIT() {
itController.closeIT();
errorService.resetError();
notifyListeners();
}
/// Fetches the language help for the current text, including grammar correction, language detection,
/// tokens, and translations. Includes logic to exit the flow if the user is not subscribed, if the tools are not enabled, or
/// or if autoIGC is not enabled and the user has not manually requested it.
/// [onlyTokensAndLanguageDetection] will
Future<void> getLanguageHelp({
bool manual = false,
}) async {
try {
if (errorService.isError) return;
final SubscriptionStatus canSendStatus =
pangeaController.subscriptionController.subscriptionStatus;
if (canSendStatus != SubscriptionStatus.subscribed ||
l2Lang == null ||
l1Lang == null ||
(!igcEnabled && !itEnabled) ||
(!isAutoIGCEnabled && !manual && choreoMode != ChoreoMode.it)) {
return;
}
_resetDebounceTimer();
startLoading();
_initChoreoRecord();
// if getting language assistance after finishing IT,
// reset the itController
if (choreoMode == ChoreoMode.it && itController.isTranslationDone) {
itController.clear();
}
await (isRunningIT
? itController.getTranslationData(_useCustomInput)
: igc.getIGCTextData());
} catch (err, stack) {
ErrorHandler.logError(
e: err,
s: stack,
data: {
"l2Lang": l2Lang?.toJson(),
"l1Lang": l1Lang?.toJson(),
"choreoMode": choreoMode,
"igcEnabled": igcEnabled,
"itEnabled": itEnabled,
"isAutoIGCEnabled": isAutoIGCEnabled,
"isTranslationDone": itController.isTranslationDone,
"useCustomInput": _useCustomInput,
},
);
} finally {
stopLoading();
}
Continuance onSelectContinuance(int index) {
final continuance = itController.onSelectContinuance(index);
notifyListeners();
return continuance;
}
void onITChoiceSelect(ITStep step) {
_textController.setSystemText(
_textController.text + step.continuances[step.chosen!].text,
step.continuances[step.chosen!].gold
? EditType.itGold
: EditType.itStandard,
void onAcceptContinuance(int index) {
final step = itController.getAcceptedITStep(index);
textController.setSystemText(
textController.text + step.continuances[step.chosen].text,
EditType.it,
);
textController.selection = TextSelection.collapsed(
offset: textController.text.length,
);
_textController.selection =
TextSelection.collapsed(offset: _textController.text.length);
_initChoreoRecord();
choreoRecord!.addRecord(_textController.text, step: step);
giveInputFocus();
_choreoRecord!.addRecord(textController.text, step: step);
chatController.inputFocus.requestFocus();
notifyListeners();
}
Future<void> onAcceptReplacement({
void setSourceText(String? text) {
_sourceText = text;
}
void setEditingSourceText(bool value) {
itController.setEditing(value);
notifyListeners();
}
void submitSourceTextEdits(String text) {
_sourceText = text;
itController.onSubmitEdits();
notifyListeners();
}
PangeaMatchState? getMatchByOffset(int offset) =>
igc.getMatchByOffset(offset);
void clearMatches(Object error) {
MatrixState.pAnyState.closeAllOverlays();
igc.clearMatches();
errorService.setError(ChoreoError(raw: error));
}
Future<void> fetchSpanDetails({
required PangeaMatchState match,
}) async {
try {
if (igc.igcTextData == null) {
ErrorHandler.logError(
e: "onReplacementSelect with null igcTextData",
s: StackTrace.current,
data: {
"match": match.toJson(),
},
);
MatrixState.pAnyState.closeOverlay();
return;
}
if (match.updatedMatch.match.selectedChoice == null) {
ErrorHandler.logError(
e: "onReplacementSelect with null selectedChoice",
s: StackTrace.current,
data: {
"igctextData": igc.igcTextData?.toJson(),
"match": match.toJson(),
},
);
MatrixState.pAnyState.closeOverlay();
return;
}
final isNormalizationError =
match.updatedMatch.match.isNormalizationError();
final updatedMatch = igc.igcTextData!.acceptReplacement(
match,
PangeaMatchStatus.accepted,
bool force = false,
}) =>
igc.fetchSpanDetails(
match: match,
force: force,
);
_textController.setSystemText(
igc.igcTextData!.currentText,
EditType.igc,
);
void onAcceptReplacement({
required PangeaMatchState match,
}) {
final updatedMatch = igc.acceptReplacement(
match,
PangeaMatchStatus.accepted,
);
//if it's the right choice, replace in text
if (!isNormalizationError) {
_initChoreoRecord();
choreoRecord!.addRecord(
_textController.text,
match: updatedMatch,
);
}
textController.setSystemText(
igc.currentText!,
EditType.igc,
);
MatrixState.pAnyState.closeOverlay();
setState();
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: err,
s: stack,
data: {
"igctextData": igc.igcTextData?.toJson(),
"match": match.toJson(),
},
if (!updatedMatch.match.isNormalizationError()) {
_initChoreoRecord();
_choreoRecord!.addRecord(
textController.text,
match: updatedMatch,
);
igc.clear();
} finally {
setState();
}
MatrixState.pAnyState.closeOverlay();
notifyListeners();
}
void onUndoReplacement(PangeaMatchState match) {
try {
igc.igcTextData?.undoReplacement(match);
choreoRecord?.choreoSteps.removeWhere(
(step) => step.acceptedOrIgnoredMatch == match.updatedMatch,
);
igc.undoReplacement(match);
_choreoRecord?.choreoSteps.removeWhere(
(step) => step.acceptedOrIgnoredMatch == match.updatedMatch,
);
_textController.setSystemText(
igc.igcTextData!.currentText,
EditType.igc,
textController.setSystemText(
igc.currentText!,
EditType.igc,
);
MatrixState.pAnyState.closeOverlay();
notifyListeners();
}
void onIgnoreMatch({required PangeaMatchState match}) {
final updatedMatch = igc.ignoreReplacement(match);
if (!updatedMatch.match.isNormalizationError()) {
_initChoreoRecord();
_choreoRecord!.addRecord(
textController.text,
match: updatedMatch,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"igctextData": igc.igcTextData?.toJson(),
"match": match.toJson(),
},
);
} finally {
MatrixState.pAnyState.closeOverlay();
setState();
}
MatrixState.pAnyState.closeOverlay();
notifyListeners();
}
void acceptNormalizationMatches() {
final normalizationsMatches = igc.igcTextData!.openNormalizationMatches;
if (normalizationsMatches.isEmpty) return;
final normalizationsMatches = igc.openNormalizationMatches;
if (normalizationsMatches?.isEmpty ?? true) return;
_initChoreoRecord();
for (final match in normalizationsMatches) {
for (final match in normalizationsMatches!) {
match.selectChoice(
match.updatedMatch.match.choices!.indexWhere(
(c) => c.isBestCorrection,
),
);
final updatedMatch = igc.igcTextData!.acceptReplacement(
final updatedMatch = igc.acceptReplacement(
match,
PangeaMatchStatus.automatic,
);
_textController.setSystemText(
igc.igcTextData!.currentText,
textController.setSystemText(
igc.currentText!,
EditType.igc,
);
choreoRecord!.addRecord(
_choreoRecord!.addRecord(
currentText,
match: updatedMatch,
);
}
}
void onIgnoreMatch({required PangeaMatchState match}) {
try {
if (igc.igcTextData == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "should not be in onIgnoreMatch with null igcTextData",
s: StackTrace.current,
data: {},
);
return;
}
final updatedMatch = igc.igcTextData!.ignoreReplacement(match);
igc.onIgnoreMatch(updatedMatch);
if (!updatedMatch.match.isNormalizationError()) {
_initChoreoRecord();
choreoRecord!.addRecord(
_textController.text,
match: updatedMatch,
);
}
} catch (err, stack) {
debugger(when: kDebugMode);
Sentry.addBreadcrumb(
Breadcrumb(
data: {
"igcTextData": igc.igcTextData?.toJson(),
"match": match.toJson(),
},
),
);
ErrorHandler.logError(
e: err,
s: stack,
data: {
"igctextData": igc.igcTextData?.toJson(),
},
);
igc.clear();
} finally {
setState();
}
}
void giveInputFocus() {
Future.delayed(Duration.zero, () {
chatController.inputFocus.requestFocus();
});
}
String get currentText => _textController.text;
PangeaTextController get textController => _textController;
String get accessToken => pangeaController.userController.accessToken;
clear() {
choreoMode = ChoreoMode.igc;
_lastChecked = null;
_timesClicked = 0;
isFetching = false;
choreoRecord = null;
translatedText = null;
itController.clear();
igc.clear();
_resetDebounceTimer();
}
Future<void> onPaste(value) async {
_initChoreoRecord();
choreoRecord!.pastedStrings.add(value);
}
dispose() {
_textController.dispose();
_languageStream?.cancel();
_settingsUpdateStream?.cancel();
stateStream.close();
TtsController.stop();
}
LanguageModel? get l2Lang {
return pangeaController.languageController.activeL2Model();
}
String? get l2LangCode => l2Lang?.langCode;
LanguageModel? get l1Lang =>
pangeaController.languageController.activeL1Model();
String? get l1LangCode => l1Lang?.langCode;
String? get userId => pangeaController.userController.userId;
bool get _noChange =>
_lastChecked != null && _lastChecked == _textController.text;
bool get isRunningIT =>
choreoMode == ChoreoMode.it && !itController.isTranslationDone;
void startLoading() {
_lastChecked = _textController.text;
isFetching = true;
setState();
}
void stopLoading() {
isFetching = false;
setState();
}
void incrementTimesClicked() {
if (assistanceState == AssistanceState.fetched) {
_timesClicked++;
// if user is doing IT, call closeIT here to
// ensure source text is replaced when needed
if (itController.isOpen && _timesClicked > 1) {
itController.closeIT();
}
}
}
get roomId => chatController.roomId;
bool get _useCustomInput => [
EditType.keyboard,
EditType.igc,
EditType.alternativeTranslation,
].contains(_textController.editType);
bool get editTypeIsKeyboard => EditType.keyboard == _textController.editType;
setState() {
if (!stateStream.isClosed) {
stateStream.add(0);
}
_currentAssistanceState = assistanceState;
}
LayerLinkAndKey get itBarLinkAndKey =>
MatrixState.pAnyState.layerLinkAndKey(itBarTransformTargetKey);
String get itBarTransformTargetKey => 'it_bar$roomId';
LayerLinkAndKey get inputLayerLinkAndKey =>
MatrixState.pAnyState.layerLinkAndKey(inputTransformTargetKey);
String get inputTransformTargetKey => 'input$roomId';
LayerLinkAndKey get itBotLayerLinkAndKey =>
MatrixState.pAnyState.layerLinkAndKey(itBotTransformTargetKey);
String get itBotTransformTargetKey => 'itBot$roomId';
bool get igcEnabled => pangeaController.permissionsController.isToolEnabled(
ToolSetting.interactiveGrammar,
chatController.room,
);
bool get itEnabled => pangeaController.permissionsController.isToolEnabled(
ToolSetting.interactiveTranslator,
chatController.room,
);
bool get isAutoIGCEnabled =>
pangeaController.permissionsController.isToolEnabled(
ToolSetting.autoIGC,
chatController.room,
);
AssistanceState get assistanceState {
final isSubscribed = pangeaController.subscriptionController.isSubscribed;
if (isSubscribed != null && !isSubscribed) {
return AssistanceState.noSub;
}
if (currentText.isEmpty && itController.sourceText == null) {
return AssistanceState.noMessage;
}
if ((igc.igcTextData?.hasOpenMatches ?? false) || isRunningIT) {
return AssistanceState.fetched;
}
if (isFetching) {
return AssistanceState.fetching;
}
if (igc.igcTextData == null) {
return AssistanceState.notFetched;
}
return AssistanceState.complete;
}
bool get canSendMessage {
// if there's an error, let them send. we don't want to block them from sending in this case
if (errorService.isError ||
l2Lang == null ||
l1Lang == null ||
_timesClicked > 1) {
return true;
}
// if they're in IT mode, don't let them send
if (itEnabled && isRunningIT) return false;
// if they've turned off IGC then let them send the message when they want
if (!isAutoIGCEnabled) return true;
// if we're in the middle of fetching results, don't let them send
if (isFetching) return false;
// they're supposed to run IGC but haven't yet, don't let them send
if (igc.igcTextData == null) {
return itController.dismissed;
}
// if they have relevant matches, don't let them send
final hasITMatches = igc.igcTextData!.hasOpenITMatches;
final hasIGCMatches = igc.igcTextData!.hasOpenIGCMatches;
if ((itEnabled && hasITMatches) || (igcEnabled && hasIGCMatches)) {
return false;
}
// otherwise, let them send
return true;
notifyListeners();
}
}

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import '../../common/utils/error_handler.dart';
class ChoreoError {
@ -16,17 +15,14 @@ class ChoreoError {
IconData get icon => Icons.error_outline;
}
class ErrorService {
class ErrorService extends ChangeNotifier {
ChoreoError? _error;
int coolDownSeconds = 0;
final Choreographer controller;
ErrorService(this.controller);
ErrorService();
bool get isError => _error != null;
ChoreoError? get error => _error;
Duration get defaultCooldown {
coolDownSeconds += 3;
return Duration(seconds: coolDownSeconds);
@ -34,7 +30,7 @@ class ErrorService {
final List<String> _errorCache = [];
setError(ChoreoError? error, {Duration? duration}) {
void setError(ChoreoError? error) {
if (_errorCache.contains(error?.raw.toString())) {
return;
}
@ -44,25 +40,21 @@ class ErrorService {
}
_error = error;
Future.delayed(duration ?? defaultCooldown, () {
Future.delayed(defaultCooldown, () {
clear();
_setState();
notifyListeners();
});
_setState();
notifyListeners();
}
setErrorAndLock(ChoreoError? error) {
void setErrorAndLock(ChoreoError? error) {
_error = error;
_setState();
notifyListeners();
}
resetError() {
void resetError() {
clear();
_setState();
}
void _setState() {
controller.setState();
notifyListeners();
}
void clear() {

View file

@ -0,0 +1,26 @@
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/spaces/models/space_model.dart';
extension ChoregrapherUserSettingsExtension on Choreographer {
LanguageModel? get l2Lang =>
pangeaController.languageController.activeL2Model();
String? get l2LangCode => l2Lang?.langCode;
LanguageModel? get l1Lang =>
pangeaController.languageController.activeL1Model();
String? get l1LangCode => l1Lang?.langCode;
bool get igcEnabled => pangeaController.permissionsController.isToolEnabled(
ToolSetting.interactiveGrammar,
chatController.room,
);
bool get itEnabled => pangeaController.permissionsController.isToolEnabled(
ToolSetting.interactiveTranslator,
chatController.room,
);
bool get isAutoIGCEnabled =>
pangeaController.permissionsController.isToolEnabled(
ToolSetting.autoIGC,
chatController.room,
);
}

View file

@ -0,0 +1,72 @@
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart';
import 'package:fluffychat/pangea/choreographer/models/it_step.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart';
extension ChoregrapherUserSettingsExtension on Choreographer {
bool get isITOpen => itController.open;
bool get isEditingSourceText => itController.editing;
bool get isITDone => itController.isTranslationDone;
bool get isRunningIT => choreoMode == ChoreoMode.it && !isITDone;
List<Continuance>? get itStepContinuances => itController.continuances;
String? get currentIGCText => igc.currentText;
PangeaMatchState? get openIGCMatch => igc.openMatch;
PangeaMatchState? get firstIGCMatch => igc.firstOpenMatch;
List<PangeaMatchState>? get openIGCMatches => igc.openMatches;
List<PangeaMatchState>? get closedIGCMatches => igc.closedMatches;
bool get canShowFirstIGCMatch => igc.canShowFirstMatch;
bool get hasIGCTextData => igc.hasIGCTextData;
AssistanceState get assistanceState {
final isSubscribed = pangeaController.subscriptionController.isSubscribed;
if (isSubscribed == false) return AssistanceState.noSub;
if (currentText.isEmpty && sourceText == null) {
return AssistanceState.noMessage;
}
if (igc.hasOpenMatches || isRunningIT) {
return AssistanceState.fetched;
}
if (isFetching) return AssistanceState.fetching;
if (!igc.hasIGCTextData) return AssistanceState.notFetched;
return AssistanceState.complete;
}
bool get canSendMessage {
// if there's an error, let them send. we don't want to block them from sending in this case
if (errorService.isError ||
l2Lang == null ||
l1Lang == null ||
timesClicked > 1) {
return true;
}
// if they're in IT mode, don't let them send
if (itEnabled && isRunningIT) return false;
// if they've turned off IGC then let them send the message when they want
if (!isAutoIGCEnabled) return true;
// if we're in the middle of fetching results, don't let them send
if (isFetching) return false;
// they're supposed to run IGC but haven't yet, don't let them send
if (!igc.hasIGCTextData) {
return itController.dismissed;
}
// if they have relevant matches, don't let them send
final hasITMatches = igc.hasOpenITMatches;
final hasIGCMatches = igc.hasOpenIGCMatches;
if ((itEnabled && hasITMatches) || (igcEnabled && hasIGCMatches)) {
return false;
}
// otherwise, let them send
return true;
}
}

View file

@ -0,0 +1,12 @@
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/common/utils/any_state_holder.dart';
import 'package:fluffychat/widgets/matrix.dart';
extension ChoregrapherUserSettingsExtension on Choreographer {
LayerLinkAndKey get itBarLinkAndKey =>
MatrixState.pAnyState.layerLinkAndKey(itBarTransformTargetKey);
String get itBarTransformTargetKey => 'it_bar${chatController.roomId}';
LayerLinkAndKey get inputLayerLinkAndKey =>
MatrixState.pAnyState.layerLinkAndKey(inputTransformTargetKey);
String get inputTransformTargetKey => 'input${chatController.roomId}';
}

View file

@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart';
import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart';
import 'package:fluffychat/pangea/choreographer/models/igc_text_data_model.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart';
@ -16,37 +17,103 @@ import 'package:fluffychat/pangea/choreographer/repo/igc_repo.dart';
import 'package:fluffychat/pangea/choreographer/repo/igc_request_model.dart';
import 'package:fluffychat/pangea/choreographer/repo/span_data_repo.dart';
import 'package:fluffychat/pangea/choreographer/repo/span_data_request.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../common/utils/error_handler.dart';
import '../../common/utils/overlay.dart';
class IgcController {
Choreographer choreographer;
IGCTextData? igcTextData;
final Choreographer _choreographer;
IGCTextData? _igcTextData;
IgcController(this.choreographer);
IgcController(this._choreographer);
String? get currentText => _igcTextData?.currentText;
bool get hasOpenMatches => _igcTextData?.hasOpenMatches == true;
bool get hasOpenITMatches => _igcTextData?.hasOpenITMatches == true;
bool get hasOpenIGCMatches => _igcTextData?.hasOpenIGCMatches == true;
PangeaMatchState? get openMatch => _igcTextData?.openMatch;
PangeaMatchState? get firstOpenMatch => _igcTextData?.firstOpenMatch;
List<PangeaMatchState>? get openMatches => _igcTextData?.openMatches;
List<PangeaMatchState>? get closedMatches => _igcTextData?.closedMatches;
List<PangeaMatchState>? get openNormalizationMatches =>
_igcTextData?.openNormalizationMatches;
bool get canShowFirstMatch => _igcTextData?.firstOpenMatch != null;
bool get hasIGCTextData {
if (_igcTextData == null) return false;
return _igcTextData!.currentText == _choreographer.currentText;
}
void clear() {
_igcTextData = null;
MatrixState.pAnyState.closeAllOverlays();
}
void clearMatches() => _igcTextData?.clearMatches();
PangeaMatchState? onShowFirstMatch() {
if (!canShowFirstMatch) {
throw "should not be calling showFirstMatch with this igcTextData.";
}
final match = _igcTextData!.firstOpenMatch!;
if (match.updatedMatch.isITStart && _igcTextData != null) {
_choreographer.openIT(match);
return null;
}
_choreographer.chatController.inputFocus.unfocus();
return match;
}
PangeaMatchState? getMatchByOffset(int offset) =>
_igcTextData?.getMatchByOffset(offset);
PangeaMatch acceptReplacement(
PangeaMatchState match,
PangeaMatchStatus status,
) {
if (_igcTextData == null) {
throw "acceptReplacement called with null igcTextData";
}
return _igcTextData!.acceptReplacement(match, status);
}
PangeaMatch ignoreReplacement(PangeaMatchState match) {
IgcRepo.ignore(match.updatedMatch);
if (_igcTextData == null) {
throw "should not be in onIgnoreMatch with null igcTextData";
}
return _igcTextData!.ignoreReplacement(match);
}
void undoReplacement(PangeaMatchState match) {
if (_igcTextData == null) {
throw "undoReplacement called with null igcTextData";
}
_igcTextData!.undoReplacement(match);
}
Future<void> getIGCTextData() async {
if (choreographer.currentText.isEmpty) return clear();
debugPrint('getIGCTextData called with ${choreographer.currentText}');
if (_choreographer.currentText.isEmpty) return clear();
debugPrint('getIGCTextData called with ${_choreographer.currentText}');
final IGCRequestModel reqBody = IGCRequestModel(
fullText: choreographer.currentText,
userId: choreographer.pangeaController.userController.userId!,
userL1: choreographer.l1LangCode!,
userL2: choreographer.l2LangCode!,
enableIGC:
choreographer.igcEnabled && choreographer.choreoMode != ChoreoMode.it,
enableIT:
choreographer.itEnabled && choreographer.choreoMode != ChoreoMode.it,
fullText: _choreographer.currentText,
userId: _choreographer.pangeaController.userController.userId!,
userL1: _choreographer.l1LangCode!,
userL2: _choreographer.l2LangCode!,
enableIGC: _choreographer.igcEnabled &&
_choreographer.choreoMode != ChoreoMode.it,
enableIT: _choreographer.itEnabled &&
_choreographer.choreoMode != ChoreoMode.it,
prevMessages: _prevMessages(),
);
final res = await IgcRepo.get(
choreographer.accessToken,
_choreographer.pangeaController.userController.accessToken,
reqBody,
).timeout(
(const Duration(seconds: 10)),
@ -58,78 +125,80 @@ class IgcController {
);
if (res.isError) {
choreographer.errorService.setError(ChoreoError(raw: res.error));
clear();
return;
}
// this will happen when the user changes the input while igc is fetching results
if (res.result!.originalInput.trim() != choreographer.currentText.trim()) {
return;
}
final response = res.result!;
igcTextData = IGCTextData(
originalInput: response.originalInput,
matches: response.matches,
);
choreographer.acceptNormalizationMatches();
for (final match in igcTextData!.openMatches) {
setSpanDetails(match: match);
}
}
void onIgnoreMatch(PangeaMatch match) {
IgcRepo.ignore(match);
}
bool get canShowFirstMatch {
return igcTextData?.firstOpenMatch != null;
}
void showFirstMatch(BuildContext context) {
if (!canShowFirstMatch) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "should not be calling showFirstMatch with this igcTextData.",
s: StackTrace.current,
data: {
"igcTextData": igcTextData?.toJson(),
},
_igcTextData = IGCTextData(
originalInput: reqBody.fullText,
matches: [],
);
return;
}
final match = igcTextData!.firstOpenMatch!;
if (match.updatedMatch.isITStart && igcTextData != null) {
choreographer.onITStart(match);
// this will happen when the user changes the input while igc is fetching results
if (res.result!.originalInput.trim() != _choreographer.currentText.trim()) {
return;
}
choreographer.chatController.inputFocus.unfocus();
MatrixState.pAnyState.closeAllOverlays();
OverlayUtil.showPositionedCard(
overlayKey:
"span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}",
context: context,
cardToShow: SpanCard(
match: match,
choreographer: choreographer,
),
maxHeight: 325,
maxWidth: 325,
transformTargetId: choreographer.inputTransformTargetKey,
onDismiss: () => choreographer.setState(),
ignorePointer: true,
isScrollable: false,
final response = res.result!;
_igcTextData = IGCTextData(
originalInput: response.originalInput,
matches: response.matches,
);
try {
_choreographer.acceptNormalizationMatches();
if (_igcTextData != null) {
for (final match in _igcTextData!.openMatches) {
fetchSpanDetails(match: match);
}
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
level: SentryLevel.warning,
data: {
"igcResponse": response.toJson(),
},
);
}
}
Future<void> fetchSpanDetails({
required PangeaMatchState match,
bool force = false,
}) async {
final span = match.updatedMatch.match;
if (span.isNormalizationError() && !force) {
return;
}
final response = await SpanDataRepo.get(
_choreographer.pangeaController.userController.accessToken,
request: SpanDetailsRequest(
userL1: _choreographer.l1LangCode!,
userL2: _choreographer.l2LangCode!,
enableIGC: _choreographer.igcEnabled,
enableIT: _choreographer.itEnabled,
span: span,
),
).timeout(
(const Duration(seconds: 10)),
onTimeout: () {
return Result.error(
TimeoutException('Span details request timed out'),
);
},
);
if (response.isError) {
_choreographer.clearMatches(response.error!);
return;
}
_igcTextData?.setSpanData(match, response.result!.span);
}
/// Get the content of previous text and audio messages in chat.
/// Passed to IGC request to add context.
List<PreviousMessage> _prevMessages({int numMessages = 5}) {
final List<Event> events = choreographer.chatController.visibleEvents
final List<Event> events = _choreographer.chatController.visibleEvents
.where(
(e) =>
e.type == EventTypes.Message &&
@ -144,9 +213,9 @@ class IgcController {
? event.content.toString()
: PangeaMessageEvent(
event: event,
timeline: choreographer.chatController.timeline!,
timeline: _choreographer.chatController.timeline!,
ownMessage: event.senderId ==
choreographer.pangeaController.matrixState.client.userID,
_choreographer.pangeaController.matrixState.client.userID,
).getSpeechToTextLocal()?.transcript.text.trim(); // trim whitespace
if (content == null) continue;
messages.add(
@ -162,51 +231,4 @@ class IgcController {
}
return messages;
}
bool get hasRelevantIGCTextData {
if (igcTextData == null) return false;
if (igcTextData!.currentText != choreographer.currentText) {
debugPrint(
"returning isIGCTextDataRelevant false because text has changed",
);
return false;
}
return true;
}
clear() {
igcTextData = null;
MatrixState.pAnyState.closeAllOverlays();
}
Future<void> setSpanDetails({
required PangeaMatchState match,
bool force = false,
}) async {
final span = match.updatedMatch.match;
if (span.isNormalizationError() && !force) {
return;
}
final response = await SpanDataRepo.get(
choreographer.accessToken,
request: SpanDetailsRequest(
userL1: choreographer.l1LangCode!,
userL2: choreographer.l2LangCode!,
enableIGC: choreographer.igcEnabled,
enableIT: choreographer.itEnabled,
span: span,
),
);
if (response.isError) {
choreographer.errorService.setError(ChoreoError(raw: response.error));
clear();
return;
}
igcTextData?.setSpanData(match, response.result!.span);
choreographer.setState();
}
}

View file

@ -1,17 +1,14 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:async/async.dart';
import 'package:http/http.dart' as http;
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart';
import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart';
import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart';
import 'package:fluffychat/pangea/choreographer/repo/it_repo.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../models/it_step.dart';
@ -20,121 +17,180 @@ import '../repo/it_response_model.dart';
import 'choreographer.dart';
class ITController {
Choreographer choreographer;
final Choreographer _choreographer;
bool _isOpen = false;
bool _willOpen = false;
bool _isEditingSourceText = false;
bool dismissed = false;
ITStep? _currentITStep;
final List<Completer<ITStep>> _queue = [];
GoldRouteTracker? _goldRouteTracker;
ITStartData? _itStartData;
String? sourceText;
List<ITStep> completedITSteps = [];
CurrentITStep? currentITStep;
Completer<CurrentITStep?>? nextITStep;
GoldRouteTracker goldRouteTracker = GoldRouteTracker.defaultTracker;
List<int> payLoadIds = [];
bool _open = false;
bool _editing = false;
bool _dismissed = false;
ITController(this.choreographer);
ITController(this._choreographer);
void clear() {
_isOpen = false;
_willOpen = false;
MatrixState.pAnyState.closeOverlay("it_feedback_card");
bool get open => _open;
bool get editing => _editing;
bool get dismissed => _dismissed;
List<Continuance>? get continuances => _currentITStep?.continuances;
bool get isTranslationDone => _currentITStep?.isFinal ?? false;
_isEditingSourceText = false;
dismissed = false;
String? get _sourceText => _choreographer.sourceText;
_itStartData = null;
sourceText = null;
completedITSteps = [];
currentITStep = null;
nextITStep = null;
goldRouteTracker = GoldRouteTracker.defaultTracker;
payLoadIds = [];
choreographer.choreoMode = ChoreoMode.igc;
choreographer.setState();
ITRequestModel _request(String textInput) {
assert(_sourceText != null);
return ITRequestModel(
text: _sourceText!,
customInput: textInput,
sourceLangCode:
MatrixState.pangeaController.languageController.activeL1Code()!,
targetLangCode:
MatrixState.pangeaController.languageController.activeL2Code()!,
userId: _choreographer.chatController.room.client.userID!,
roomId: _choreographer.chatController.room.id,
goldTranslation: _goldRouteTracker?.fullTranslation,
goldContinuances: _goldRouteTracker?.continuances,
);
}
Duration get animationSpeed => const Duration(milliseconds: 300);
Future<void> initializeIT(ITStartData itStartData) async {
_willOpen = true;
Future.delayed(const Duration(microseconds: 100), () {
_isOpen = true;
});
_itStartData = itStartData;
}
void openIT() => _open = true;
void closeIT() {
// if the user hasn't gone through any IT steps, reset the text
if (completedITSteps.isEmpty && sourceText != null) {
choreographer.textController.setSystemText(
sourceText!,
if (_choreographer.currentText.isEmpty && _sourceText != null) {
_choreographer.textController.setSystemText(
_sourceText!,
EditType.itDismissed,
);
}
clear();
choreographer.errorService.resetError();
dismissed = true;
clear(dismissed: true);
}
/// if IGC isn't positive that text is full L1 then translate to L1
Future<void> _setSourceText() async {
if (_itStartData == null || _itStartData!.text.isEmpty) {
Sentry.addBreadcrumb(
Breadcrumb(
message: "choreo context",
data: {
"igcTextData": choreographer.igc.igcTextData?.toJson(),
"currentText": choreographer.currentText,
},
),
);
throw Exception("null _itStartData or empty text in _setSourceText");
}
debugPrint("_setSourceText with detectedLang ${_itStartData!.langCode}");
// if (_itStartData!.langCode == choreographer.l1LangCode) {
sourceText = _itStartData!.text;
choreographer.translatedText = sourceText;
return;
// }
void clear({bool dismissed = false}) {
MatrixState.pAnyState.closeOverlay("it_feedback_card");
// final FullTextTranslationResponseModel res =
// await FullTextTranslationRepo.translate(
// accessToken: await choreographer.accessToken,
// request: FullTextTranslationRequestModel(
// text: _itStartData!.text,
// tgtLang: choreographer.l1LangCode!,
// srcLang: _itStartData!.langCode,
// userL1: choreographer.l1LangCode!,
// userL2: choreographer.l2LangCode!,
// ),
// );
// sourceText = res.bestTranslation;
_open = false;
_editing = false;
_dismissed = dismissed;
_queue.clear();
_currentITStep = null;
_goldRouteTracker = null;
_choreographer.setChoreoMode(ChoreoMode.igc);
_choreographer.setSourceText(null);
}
// used 1) at very beginning (with custom input = null)
// and 2) if they make direct edits to the text field
Future<void> getTranslationData(bool useCustomInput) async {
final String currentText = choreographer.currentText;
void setEditing(bool value) => _editing = value;
if (sourceText == null) await _setSourceText();
void onSubmitEdits() {
_editing = false;
_queue.clear();
_currentITStep = null;
_goldRouteTracker = null;
continueIT();
}
if (useCustomInput && currentITStep != null) {
completedITSteps.add(
ITStep(
currentITStep!.continuances,
customInput: currentText,
),
);
Continuance onSelectContinuance(int index) {
if (_currentITStep == null) {
throw "onSelectContinuance called with null currentITStep";
}
currentITStep = null;
if (index < 0 || index >= _currentITStep!.continuances.length) {
throw "onSelectContinuance called with invalid index $index";
}
// During first IT step, next step will not be set
if (nextITStep == null) {
final step = _currentITStep!.continuances[index];
_currentITStep!.continuances[index] = step.copyWith(
wasClicked: true,
);
return _currentITStep!.continuances[index];
}
CompletedITStep getAcceptedITStep(int chosenIndex) {
if (_currentITStep == null) {
throw "getAcceptedITStep called with null currentITStep";
}
if (chosenIndex < 0 || chosenIndex >= _currentITStep!.continuances.length) {
throw "getAcceptedITStep called with invalid index $chosenIndex";
}
return CompletedITStep(
_currentITStep!.continuances,
chosen: chosenIndex,
);
}
Future<void> continueIT() async {
if (_currentITStep == null) {
await _initTranslationData();
return;
}
if (_queue.isEmpty) {
_choreographer.closeIT();
return;
}
final nextStepCompleter = _queue.removeAt(0);
try {
_currentITStep = await nextStepCompleter.future;
} catch (e) {
if (_open) {
_choreographer.errorService.setErrorAndLock(
ChoreoError(raw: e),
);
}
}
}
Future<void> _initTranslationData() async {
final String currentText = _choreographer.currentText;
final res = await ITRepo.get(_request(currentText)).timeout(
const Duration(seconds: 10),
onTimeout: () {
return Result.error(
TimeoutException("ITRepo.get timed out after 10 seconds"),
);
},
);
if (_sourceText == null || !_open) return;
if (res.isError || res.result?.goldContinuances == null) {
_choreographer.errorService.setErrorAndLock(
ChoreoError(raw: res.asError),
);
return;
}
final result = res.result!;
_goldRouteTracker = GoldRouteTracker(
result.goldContinuances!,
_sourceText!,
);
_currentITStep = ITStep(
sourceText: _sourceText!,
currentText: currentText,
responseModel: res.result!,
storedGoldContinuances: _goldRouteTracker!.continuances,
);
_fillITStepQueue();
}
Future<void> _fillITStepQueue() async {
if (_sourceText == null || _goldRouteTracker!.continuances.length < 2) {
return;
}
final sourceText = _sourceText!;
String currentText =
_choreographer.currentText + _goldRouteTracker!.continuances[0].text;
for (int i = 1; i < _goldRouteTracker!.continuances.length; i++) {
_queue.add(Completer<ITStep>());
final res = await ITRepo.get(_request(currentText)).timeout(
const Duration(seconds: 10),
onTimeout: () {
@ -145,192 +201,29 @@ class ITController {
);
if (res.isError) {
if (_willOpen) {
choreographer.errorService.setErrorAndLock(
ChoreoError(raw: res.asError),
);
}
return;
}
if (sourceText == null) {
return;
}
final result = res.result!;
if (result.goldContinuances != null &&
result.goldContinuances!.isNotEmpty) {
goldRouteTracker = GoldRouteTracker(
result.goldContinuances!,
sourceText!,
_queue.last.completeError(res.asError!);
break;
} else {
final step = ITStep(
sourceText: sourceText,
currentText: currentText,
responseModel: res.result!,
storedGoldContinuances: _goldRouteTracker!.continuances,
);
_queue.last.complete(step);
}
currentITStep = CurrentITStep(
sourceText: sourceText!,
currentText: currentText,
responseModel: result,
storedGoldContinuances: goldRouteTracker.continuances,
);
_addPayloadId(result);
} else {
currentITStep = await nextITStep!.future;
}
if (isTranslationDone) {
nextITStep = null;
closeIT();
} else {
nextITStep = Completer<CurrentITStep?>();
final nextStep = await _getNextTranslationData();
nextITStep?.complete(nextStep);
currentText += _goldRouteTracker!.continuances[i].text;
}
}
Future<CurrentITStep?> _getNextTranslationData() async {
if (sourceText == null) {
ErrorHandler.logError(
e: Exception("sourceText is null in getNextTranslationData"),
data: {
"sourceText": sourceText,
"currentITStepPayloadID": currentITStep?.payloadId,
"continuances": goldRouteTracker.continuances.map((e) => e.toJson()),
},
);
return null;
}
if (completedITSteps.length >= goldRouteTracker.continuances.length) {
return null;
}
final String currentText = choreographer.currentText;
final String nextText =
goldRouteTracker.continuances[completedITSteps.length].text;
final res = await ITRepo.get(
_request(currentText + nextText),
);
if (sourceText == null) return null;
if (res.isError) {
choreographer.errorService.setErrorAndLock(
ChoreoError(raw: res.asError),
);
return null;
}
return CurrentITStep(
sourceText: sourceText!,
currentText: nextText,
responseModel: res.result!,
storedGoldContinuances: goldRouteTracker.continuances,
);
}
Future<void> onEditSourceTextSubmit(String newSourceText) async {
try {
_isOpen = true;
_isEditingSourceText = false;
_itStartData = ITStartData(newSourceText, choreographer.l1LangCode);
completedITSteps = [];
currentITStep = null;
nextITStep = null;
goldRouteTracker = GoldRouteTracker.defaultTracker;
payLoadIds = [];
_setSourceText();
getTranslationData(false);
} catch (err, stack) {
debugger(when: kDebugMode);
if (err is! http.Response) {
ErrorHandler.logError(
e: err,
s: stack,
data: {
"newSourceText": newSourceText,
"l1Lang": choreographer.l1LangCode,
},
);
}
choreographer.errorService.setErrorAndLock(
ChoreoError(raw: err),
);
} finally {
choreographer.textController.setSystemText(
"",
EditType.other,
);
}
}
ITRequestModel _request(String textInput) => ITRequestModel(
text: sourceText!,
customInput: textInput,
sourceLangCode: sourceLangCode,
targetLangCode: targetLangCode,
userId: choreographer.userId!,
roomId: choreographer.roomId!,
goldTranslation: goldRouteTracker.fullTranslation,
goldContinuances: goldRouteTracker.continuances,
);
//maybe we store IT data in the same format? make a specific kind of match?
void selectTranslation(int chosenIndex) {
if (currentITStep == null) return;
final itStep = ITStep(
currentITStep!.continuances,
chosen: chosenIndex,
);
completedITSteps.add(itStep);
choreographer.onITChoiceSelect(itStep);
choreographer.setState();
}
String get uniqueKeyForLayerLink => "itChoices${choreographer.roomId}";
void _addPayloadId(ITResponseModel res) {
payLoadIds.add(res.payloadId);
}
bool get isTranslationDone => currentITStep != null && currentITStep!.isFinal;
bool get isOpen => _isOpen;
bool get willOpen => _willOpen;
String get targetLangCode => choreographer.l2LangCode!;
String get sourceLangCode => choreographer.l1LangCode!;
bool get isLoading => choreographer.isFetching;
void setIsEditingSourceText(bool value) {
_isEditingSourceText = value;
choreographer.setState();
}
bool get isEditingSourceText => _isEditingSourceText;
}
class ITStartData {
String text;
String? langCode;
ITStartData(this.text, this.langCode);
}
class GoldRouteTracker {
late String _originalText;
List<Continuance> continuances;
final String _originalText;
final List<Continuance> continuances;
GoldRouteTracker(this.continuances, String originalText) {
_originalText = originalText;
}
static get defaultTracker => GoldRouteTracker([], "");
const GoldRouteTracker(this.continuances, String originalText)
: _originalText = originalText;
Continuance? currentContinuance({
required String currentText,
@ -362,13 +255,11 @@ class GoldRouteTracker {
}
}
class CurrentITStep {
class ITStep {
late List<Continuance> continuances;
late bool isFinal;
late String? translationId;
late int payloadId;
CurrentITStep({
ITStep({
required String sourceText,
required String currentText,
required ITResponseModel responseModel,
@ -379,8 +270,6 @@ class CurrentITStep {
final goldTracker = GoldRouteTracker(gold, sourceText);
isFinal = responseModel.isFinal;
translationId = responseModel.translationId;
payloadId = responseModel.payloadId;
if (responseModel.continuances.isEmpty) {
continuances = [];
@ -410,8 +299,4 @@ class CurrentITStep {
}
}
}
// get continuance with highest level
Continuance get best =>
continuances.reduce((a, b) => a.level < b.level ? a : b);
}

View file

@ -1,122 +1,96 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/constants/match_rule_ids.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart';
import 'package:fluffychat/pangea/choreographer/utils/match_style_util.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/autocorrect_span.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../common/utils/overlay.dart';
import '../enums/edit_type.dart';
import 'choreographer.dart';
class PangeaTextController extends TextEditingController {
Choreographer choreographer;
final Choreographer choreographer;
EditType editType = EditType.keyboard;
String _currentText = '';
PangeaTextController({
String? text,
required this.choreographer,
}) {
text ??= '';
this.text = text;
addListener(() {
final difference =
text.characters.length - _currentText.characters.length;
if (difference > 1 && editType == EditType.keyboard) {
choreographer.onPaste(
text.characters
.getRange(
_currentText.characters.length,
text.characters.length,
)
.join(),
);
}
_currentText = text;
});
}
static const int maxLength = 1000;
bool get exceededMaxLength => text.length >= maxLength;
bool get exceededMaxLength => text.length >= ChoreoConstants.maxLength;
bool forceKeepOpen = false;
TextStyle _underlineStyle(Color color) => TextStyle(
decoration: TextDecoration.underline,
decorationColor: color,
decorationThickness: 5,
);
Color _underlineColor(PangeaMatch match) {
if (match.status == PangeaMatchStatus.automatic) {
return const Color.fromARGB(187, 132, 96, 224);
}
switch (match.match.rule?.id ?? "unknown") {
case MatchRuleIds.interactiveTranslation:
return const Color.fromARGB(187, 132, 96, 224);
case MatchRuleIds.tokenNeedsTranslation:
case MatchRuleIds.tokenSpanNeedsTranslation:
return const Color.fromARGB(186, 255, 132, 0);
default:
return const Color.fromARGB(149, 255, 17, 0);
}
}
TextStyle _textStyle(
PangeaMatch match,
TextStyle? existingStyle,
bool isOpenMatch,
) {
double opacityFactor = 1.0;
if (!isOpenMatch) {
opacityFactor = 0.2;
}
final alpha = (255 * opacityFactor).round();
final style = _underlineStyle(_underlineColor(match).withAlpha(alpha));
return existingStyle?.merge(style) ?? style;
}
void setSystemText(String text, EditType type) {
editType = type;
this.text = text;
}
void onInputTap(BuildContext context, {required FocusNode fNode}) {
fNode.requestFocus();
forceKeepOpen = true;
if (!context.mounted) {
debugger(when: kDebugMode);
return;
}
// show the paywall if appropriate
if (choreographer
.pangeaController.subscriptionController.subscriptionStatus ==
SubscriptionStatus.shouldShowPaywall &&
!choreographer.isFetching &&
text.isNotEmpty) {
PaywallCard.show(context, choreographer.chatController);
return;
}
// if there is no igc text data, then don't do anything
if (choreographer.igc.igcTextData == null) return;
// debugPrint(
// "onInputTap matches are ${choreographer.igc.igcTextData?.matches.map((e) => e.match.rule.id).toList().toString()}");
// if user is just trying to get their cursor into the text input field to add soemthing,
// then don't interrupt them
if (selection.baseOffset >= text.length) {
return;
}
final match = choreographer.igc.igcTextData!.getMatchByOffset(
selection.baseOffset,
);
if (match == null) return;
// if autoplay on and it start then just start it
if (match.updatedMatch.isITStart) {
return choreographer.onITStart(match);
}
MatrixState.pAnyState.closeAllOverlays();
OverlayUtil.showPositionedCard(
overlayKey:
"span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}",
context: context,
maxHeight: 400,
maxWidth: 350,
cardToShow: SpanCard(
match: match,
choreographer: choreographer,
),
transformTargetId: choreographer.inputTransformTargetKey,
onDismiss: () => choreographer.setState(),
ignorePointer: true,
isScrollable: false,
);
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
// If the composing range is out of range for the current text, ignore it to
// preserve the tree integrity, otherwise in release mode a RangeError will
// be thrown and this EditableText will be built with a broken subtree.
// debugPrint("composing? $withComposing");
// if (!value.isComposingRangeValid || !withComposing) {
// debugPrint("just returning straight text");
// // debugger(when: kDebugMode);
// return TextSpan(style: style, text: text);
// }
// if (value.isComposingRangeValid) {
// debugPrint("composing before ${value.composing.textBefore(value.text)}");
// debugPrint("composing inside ${value.composing.textInside(value.text)}");
// debugPrint("composing after ${value.composing.textAfter(value.text)}");
// }
final SubscriptionStatus canSendStatus = choreographer
.pangeaController.subscriptionController.subscriptionStatus;
if (canSendStatus == SubscriptionStatus.shouldShowPaywall &&
@ -125,33 +99,38 @@ class PangeaTextController extends TextEditingController {
return TextSpan(
text: text,
style: style?.merge(
MatchStyleUtil.underlineStyle(
_underlineStyle(
const Color.fromARGB(187, 132, 96, 224),
),
),
);
} else if (choreographer.igc.igcTextData == null || text.isEmpty) {
} else if (!choreographer.hasIGCTextData || text.isEmpty) {
return TextSpan(text: text, style: style);
} else {
final parts = text.split(choreographer.igc.igcTextData!.currentText);
final parts = text.split(choreographer.currentIGCText!);
if (parts.length == 1 || parts.length > 2) {
return TextSpan(text: text, style: style);
}
List<InlineSpan> inlineSpans = [];
try {
inlineSpans = constructTokenSpan(
defaultStyle: style,
onUndo: choreographer.onUndoReplacement,
);
} catch (e) {
choreographer.errorService.setError(
ChoreoError(raw: e),
);
inlineSpans = [TextSpan(text: text, style: style)];
choreographer.igc.clear();
}
final inlineSpans = constructTokenSpan(
defaultStyle: style,
onUndo: (match) {
try {
choreographer.onUndoReplacement(match);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
level: SentryLevel.warning,
data: {
"match": match.toJson(),
},
);
MatrixState.pAnyState.closeOverlay();
choreographer.clearMatches(e);
}
},
);
return TextSpan(
style: style,
@ -169,7 +148,7 @@ class PangeaTextController extends TextEditingController {
VoidCallback onUndo,
) {
if (match.updatedMatch.status == PangeaMatchStatus.automatic) {
final span = choreographer.igc.igcTextData!.currentText.characters
final span = choreographer.currentIGCText!.characters
.getRange(
match.updatedMatch.match.offset,
match.updatedMatch.match.offset + match.updatedMatch.match.length,
@ -193,7 +172,7 @@ class PangeaTextController extends TextEditingController {
);
} else {
return TextSpan(
text: choreographer.igc.igcTextData!.currentText.characters
text: choreographer.currentIGCText!.characters
.getRange(
match.updatedMatch.match.offset,
match.updatedMatch.match.offset + match.updatedMatch.match.length,
@ -210,20 +189,20 @@ class PangeaTextController extends TextEditingController {
required void Function(PangeaMatchState) onUndo,
TextStyle? defaultStyle,
}) {
final automaticMatches = choreographer.igc.igcTextData!.closedMatches
.where((m) => m.updatedMatch.status == PangeaMatchStatus.automatic)
.toList();
final automaticMatches = choreographer.closedIGCMatches
?.where((m) => m.updatedMatch.status == PangeaMatchStatus.automatic)
.toList() ??
[];
final textSpanMatches = [
...choreographer.igc.igcTextData!.openMatches,
...choreographer.openIGCMatches ?? [],
...automaticMatches,
]..sort(
(a, b) =>
a.updatedMatch.match.offset.compareTo(b.updatedMatch.match.offset),
);
final currentText = choreographer.igc.igcTextData!.currentText;
final currentText = choreographer.currentIGCText!;
final spans = <InlineSpan>[];
int cursor = 0;
@ -235,9 +214,8 @@ class PangeaTextController extends TextEditingController {
spans.add(TextSpan(text: text, style: defaultStyle));
}
final openMatch =
choreographer.igc.igcTextData?.openMatch?.updatedMatch.match;
final style = MatchStyleUtil.textStyle(
final openMatch = choreographer.openIGCMatch?.updatedMatch.match;
final style = _textStyle(
match.updatedMatch,
defaultStyle,
openMatch != null &&

View file

@ -0,0 +1 @@
enum ChoreoMode { igc, it }

View file

@ -1,10 +1,7 @@
enum EditType {
itStandard,
igc,
keyboard,
alternativeTranslation,
itGold,
itStart,
it,
itDismissed,
keyboard,
other,
}

View file

@ -190,7 +190,7 @@ class ChoreoRecord {
return text;
}
void addRecord(String text, {PangeaMatch? match, ITStep? step}) {
void addRecord(String text, {PangeaMatch? match, CompletedITStep? step}) {
if (match != null && step != null) {
throw Exception("match and step should not both be defined");
}
@ -243,7 +243,7 @@ class ChoreoRecordStep {
/// last step in list may contain open
final PangeaMatch? acceptedOrIgnoredMatch;
final ITStep? itStep;
final CompletedITStep? itStep;
ChoreoRecordStep({
this.edits,
@ -264,7 +264,9 @@ class ChoreoRecordStep {
acceptedOrIgnoredMatch: json[_acceptedOrIgnoredMatchKey] != null
? PangeaMatch.fromJson(json[_acceptedOrIgnoredMatchKey])
: null,
itStep: json[_stepKey] != null ? ITStep.fromJson(json[_stepKey]) : null,
itStep: json[_stepKey] != null
? CompletedITStep.fromJson(json[_stepKey])
: null,
);
}

View file

@ -46,6 +46,8 @@ class IGCTextData {
List<PangeaMatchState> get openNormalizationMatches =>
_state.openNormalizationMatches;
void clearMatches() => _state.clearMatches();
void setSpanData(PangeaMatchState match, SpanData spanData) {
_state.setSpanData(match, spanData);
}

View file

@ -88,6 +88,11 @@ class IGCTextState {
);
}
void clearMatches() {
_openMatches.clear();
_closedMatches.clear();
}
void _filterIgnoredMatches() {
for (final match in _openMatches) {
if (IgcRepo.isIgnored(match.updatedMatch)) {
@ -110,12 +115,13 @@ class IGCTextState {
PangeaMatchState match,
PangeaMatchStatus status,
) {
final openMatch = _openMatches.firstWhereOrNull(
final openMatch = _openMatches.firstWhere(
(m) => m.originalMatch == match.originalMatch,
orElse: () => throw "No open match found for acceptReplacement",
);
if (match.updatedMatch.match.selectedChoice == null) {
throw "match.match.selectedChoice is null in acceptReplacement";
throw "acceptReplacement called with null selectedChoice";
}
match.setStatus(status);
@ -132,8 +138,9 @@ class IGCTextState {
}
PangeaMatch ignoreReplacement(PangeaMatchState match) {
final openMatch = _openMatches.firstWhereOrNull(
final openMatch = _openMatches.firstWhere(
(m) => m.originalMatch == match.originalMatch,
orElse: () => throw "No open match found for ignoreReplacement",
);
match.setStatus(PangeaMatchStatus.ignored);
@ -143,8 +150,9 @@ class IGCTextState {
}
void undoReplacement(PangeaMatchState match) {
final closedMatch = _closedMatches.firstWhereOrNull(
final closedMatch = _closedMatches.firstWhere(
(m) => m.originalMatch == match.originalMatch,
orElse: () => throw "No closed match found for undoReplacement",
);
_closedMatches.remove(closedMatch);

View file

@ -3,48 +3,35 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import '../constants/choreo_constants.dart';
class ITStep {
class CompletedITStep {
final List<Continuance> continuances;
final int? chosen;
final String? customInput;
final bool showAlternativeTranslationOption = false;
final int chosen;
ITStep(
const CompletedITStep(
this.continuances, {
this.chosen,
this.customInput,
}) {
if (chosen == null && customInput == null) {
throw Exception("ITStep must have either chosen or customInput");
}
if (chosen != null && customInput != null) {
throw Exception("ITStep must have only chosen or customInput");
}
}
required this.chosen,
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['continuances'] = continuances.map((e) => e.toJson(true)).toList();
data['chosen'] = chosen;
data['custom_input'] = customInput;
return data;
}
factory ITStep.fromJson(Map<String, dynamic> json) {
factory CompletedITStep.fromJson(Map<String, dynamic> json) {
final List<Continuance> continuances = <Continuance>[];
for (final Map<String, dynamic> continuance in json['continuances']) {
continuances.add(Continuance.fromJson(continuance));
}
return ITStep(
return CompletedITStep(
continuances,
chosen: json['chosen'],
customInput: json['custom_input'],
);
}
Continuance? get chosenContinuance {
if (chosen == null) return null;
return continuances[chosen!];
return continuances[chosen];
}
}

View file

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:diacritic/diacritic.dart';
import 'package:fluffychat/pangea/choreographer/utils/text_normalization_util.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import '../enums/span_choice_type.dart';
import '../enums/span_data_type.dart';
@ -136,8 +137,33 @@ class SpanData {
final errorSpan = fullText.characters.skip(offset).take(length).toString();
return correctChoice != null &&
TextNormalizationUtil.normalizeString(correctChoice) ==
TextNormalizationUtil.normalizeString(errorSpan);
_normalizeString(correctChoice) == _normalizeString(errorSpan);
}
String _normalizeString(String input) {
try {
// Step 1: Remove diacritics (accents)
String normalized = removeDiacritics(input);
normalized = normalized.replaceAll(RegExp(r'[^\x00-\x7F]'), '');
// Step 2: Remove punctuation
normalized = normalized.replaceAll(RegExp(r'[^\w\s]'), '');
// Step 3: Convert to lowercase
normalized = normalized.toLowerCase();
// Step 4: Trim and normalize whitespace
normalized = normalized.replaceAll(RegExp(r'\s+'), ' ').trim();
return normalized.isEmpty ? input : normalized;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {'input': input},
);
return input;
}
}
@override

View file

@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart';
import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart';
class InputPasteListener {
final PangeaTextController controller;
final Function(String) onPaste;
String _currentText = '';
InputPasteListener(
this.controller,
this.onPaste,
) {
controller.addListener(() {
final difference =
controller.text.characters.length - _currentText.characters.length;
if (difference > 1 && controller.editType == EditType.keyboard) {
onPaste(
controller.text.characters
.getRange(
_currentText.characters.length,
controller.text.characters.length,
)
.join(),
);
}
_currentText = controller.text;
});
}
}

View file

@ -1,44 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/choreographer/constants/match_rule_ids.dart';
import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart';
class MatchStyleUtil {
static TextStyle underlineStyle(Color color) => TextStyle(
decoration: TextDecoration.underline,
decorationColor: color,
decorationThickness: 5,
);
static Color _underlineColor(PangeaMatch match) {
if (match.status == PangeaMatchStatus.automatic) {
return const Color.fromARGB(187, 132, 96, 224);
}
switch (match.match.rule?.id ?? "unknown") {
case MatchRuleIds.interactiveTranslation:
return const Color.fromARGB(187, 132, 96, 224);
case MatchRuleIds.tokenNeedsTranslation:
case MatchRuleIds.tokenSpanNeedsTranslation:
return const Color.fromARGB(186, 255, 132, 0);
default:
return const Color.fromARGB(149, 255, 17, 0);
}
}
static TextStyle textStyle(
PangeaMatch match,
TextStyle? existingStyle,
bool isOpenMatch,
) {
double opacityFactor = 1.0;
if (!isOpenMatch) {
opacityFactor = 0.2;
}
final alpha = (255 * opacityFactor).round();
final style = underlineStyle(_underlineColor(match).withAlpha(alpha));
return existingStyle?.merge(style) ?? style;
}
}

View file

@ -1,31 +0,0 @@
import 'package:diacritic/diacritic.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
class TextNormalizationUtil {
static String normalizeString(String input) {
try {
// Step 1: Remove diacritics (accents)
String normalized = removeDiacritics(input);
normalized = normalized.replaceAll(RegExp(r'[^\x00-\x7F]'), '');
// Step 2: Remove punctuation
normalized = normalized.replaceAll(RegExp(r'[^\w\s]'), '');
// Step 3: Convert to lowercase
normalized = normalized.toLowerCase();
// Step 4: Trim and normalize whitespace
normalized = normalized.replaceAll(RegExp(r'\s+'), ' ').trim();
return normalized.isEmpty ? input : normalized;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {'input': input},
);
return input;
}
}
}

View file

@ -3,23 +3,16 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/utils/bot_style.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
class CardErrorWidget extends StatelessWidget {
final String error;
final Choreographer? choreographer;
final int? offset;
final double maxWidth;
final double padding;
const CardErrorWidget({
super.key,
required this.error,
this.choreographer,
this.offset,
this.maxWidth = 275,
this.padding = 8,
});
@override
@ -31,7 +24,7 @@ class CardErrorWidget extends StatelessWidget {
);
return Container(
padding: EdgeInsets.all(padding),
padding: const EdgeInsets.all(8.0),
constraints: BoxConstraints(maxWidth: maxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,

View file

@ -3,20 +3,17 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/utils/bot_style.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/card_header.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LanguageMismatchPopup extends StatelessWidget {
final String targetLanguage;
final Choreographer choreographer;
final VoidCallback onUpdate;
const LanguageMismatchPopup({
super.key,
required this.targetLanguage,
required this.choreographer,
required this.onUpdate,
});

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/bot/utils/bot_style.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/card_header.dart';
@ -10,15 +9,13 @@ import 'package:fluffychat/pangea/subscription/repo/subscription_management_repo
import 'package:fluffychat/widgets/matrix.dart';
class PaywallCard extends StatelessWidget {
final ChatController chatController;
const PaywallCard({
super.key,
required this.chatController,
});
static Future<void> show(
BuildContext context,
ChatController chatController,
String targetId,
) async {
if (!MatrixState
.pangeaController.subscriptionController.shouldShowPaywall) {
@ -28,12 +25,10 @@ class PaywallCard extends StatelessWidget {
await SubscriptionManagementRepo.setDismissedPaywall();
OverlayUtil.showPositionedCard(
context: context,
cardToShow: PaywallCard(
chatController: chatController,
),
cardToShow: const PaywallCard(),
maxHeight: 325,
maxWidth: 325,
transformTargetId: chatController.choreographer.inputTransformTargetKey,
transformTargetId: targetId,
);
}
@ -91,7 +86,6 @@ class PaywallCard extends StatelessWidget {
width: double.infinity,
child: TextButton(
onPressed: () {
chatController.clearSelectedEvents();
MatrixState.pangeaController.subscriptionController
.showPaywall(context);
},

View file

@ -1,18 +1,24 @@
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/utils/bot_style.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/enums/span_choice_type.dart';
import 'package:fluffychat/pangea/choreographer/enums/span_data_type.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart';
import 'package:fluffychat/pangea/choreographer/models/span_data.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import '../../../../widgets/matrix.dart';
import '../../../bot/widgets/bot_face_svg.dart';
import '../choice_array.dart';
import 'why_button.dart';
// CTODO refactor
class SpanCard extends StatefulWidget {
final PangeaMatchState match;
final Choreographer choreographer;
@ -34,11 +40,6 @@ class SpanCardState extends State<SpanCard> {
@override
void initState() {
super.initState();
if (widget.match.updatedMatch.isITStart == true) {
_onITStart();
return;
}
getSpanDetails();
}
@ -60,7 +61,7 @@ class SpanCardState extends State<SpanCard> {
fetchingData = true;
});
await widget.choreographer.igc.setSpanDetails(
await widget.choreographer.fetchSpanDetails(
match: widget.match,
force: force,
);
@ -70,12 +71,6 @@ class SpanCardState extends State<SpanCard> {
}
}
void _onITStart() {
if (widget.choreographer.itEnabled) {
widget.choreographer.onITStart(widget.match);
}
}
void _onChoiceSelect(int index) {
widget.match.selectChoice(index);
setState(
@ -86,25 +81,53 @@ class SpanCardState extends State<SpanCard> {
}
Future<void> _onAcceptReplacement() async {
await widget.choreographer.onAcceptReplacement(
match: widget.match,
);
try {
widget.choreographer.onAcceptReplacement(
match: widget.match,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
level: SentryLevel.warning,
data: {
"match": widget.match.toJson(),
},
);
widget.choreographer.clearMatches(e);
return;
}
_showFirstMatch();
}
void _onIgnoreMatch() {
Future.delayed(
Duration.zero,
() {
widget.choreographer.onIgnoreMatch(match: widget.match);
_showFirstMatch();
},
);
try {
widget.choreographer.onIgnoreMatch(match: widget.match);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
level: SentryLevel.warning,
data: {
"match": widget.match.toJson(),
},
);
widget.choreographer.clearMatches(e);
return;
}
_showFirstMatch();
}
void _showFirstMatch() {
if (widget.choreographer.igc.canShowFirstMatch) {
widget.choreographer.igc.showFirstMatch(context);
if (widget.choreographer.canShowFirstIGCMatch) {
final igcMatch = widget.choreographer.igc.onShowFirstMatch();
OverlayUtil.showIGCMatch(
igcMatch!,
widget.choreographer,
context,
);
} else {
MatrixState.pAnyState.closeOverlay();
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
@ -18,23 +17,19 @@ import 'package:fluffychat/widgets/matrix.dart';
import 'card_error_widget.dart';
class WordDataCard extends StatefulWidget {
final bool hasInfo;
final String word;
final String fullText;
final String? choiceFeedback;
final String wordLang;
final String fullTextLang;
final Room room;
const WordDataCard({
super.key,
required this.word,
required this.wordLang,
required this.hasInfo,
required this.fullText,
required this.fullTextLang,
required this.room,
this.choiceFeedback,
required this.wordLang,
required this.fullTextLang,
});
@override

View file

@ -4,10 +4,14 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/it_controller.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/models/it_step.dart';
import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_request_model.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/word_data_card.dart';
@ -30,11 +34,7 @@ class ITBar extends StatefulWidget {
}
class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
ITController get itController => widget.choreographer.itController;
StreamSubscription? _choreoSub;
bool showedClickInstruction = false;
late AnimationController _controller;
late Animation<double> _animation;
bool wasOpen = false;
@ -44,24 +44,28 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
super.initState();
// Rebuild the widget each time there's an update from choreo.
_choreoSub = widget.choreographer.stateStream.stream.listen((_) {
if (itController.willOpen != wasOpen) {
itController.willOpen ? _controller.forward() : _controller.reverse();
widget.choreographer.addListener(() {
if (widget.choreographer.isITOpen != wasOpen) {
widget.choreographer.isITOpen
? _controller.forward()
: _controller.reverse();
}
wasOpen = itController.willOpen;
wasOpen = widget.choreographer.isITOpen;
setState(() {});
});
wasOpen = itController.willOpen;
wasOpen = widget.choreographer.isITOpen;
_controller = AnimationController(
duration: itController.animationSpeed,
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
// Start in the correct state
itController.willOpen ? _controller.forward() : _controller.reverse();
widget.choreographer.isITOpen
? _controller.forward()
: _controller.reverse();
}
bool get showITInstructionsTooltip {
@ -75,18 +79,10 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
bool get showTranslationsChoicesTooltip {
return !showedClickInstruction &&
!showITInstructionsTooltip &&
!itController.choreographer.isFetching &&
!itController.isLoading &&
!itController.isEditingSourceText &&
!itController.isTranslationDone &&
itController.currentITStep != null &&
itController.currentITStep!.continuances.isNotEmpty;
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
!widget.choreographer.isFetching &&
!widget.choreographer.isEditingSourceText &&
!widget.choreographer.isITDone &&
widget.choreographer.itStepContinuances?.isNotEmpty == true;
}
final double iconDimension = 36;
@ -130,7 +126,7 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (itController.isEditingSourceText)
if (widget.choreographer.isEditingSourceText)
Expanded(
child: Padding(
padding: const EdgeInsets.only(
@ -140,14 +136,14 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
),
child: TextField(
controller: TextEditingController(
text: itController.sourceText,
text: widget.choreographer.sourceText,
),
autofocus: true,
enableSuggestions: false,
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted:
itController.onEditSourceTextSubmit,
widget.choreographer.submitSourceTextEdits,
obscureText: false,
decoration: const InputDecoration(
border: OutlineInputBorder(),
@ -155,24 +151,21 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
),
),
),
if (!itController.isEditingSourceText &&
itController.sourceText != null)
if (!widget.choreographer.isEditingSourceText &&
widget.choreographer.sourceText != null)
SizedBox(
width: iconDimension,
height: iconDimension,
child: IconButton(
iconSize: iconSize,
color: Theme.of(context).colorScheme.primary,
onPressed: () {
if (itController.nextITStep != null) {
itController.setIsEditingSourceText(true);
}
},
onPressed: () => widget.choreographer
.setEditingSourceText(true),
icon: const Icon(Icons.edit_outlined),
// iconSize: 20,
),
),
if (!itController.isEditingSourceText)
if (!widget.choreographer.isEditingSourceText)
SizedBox(
width: iconDimension,
height: iconDimension,
@ -195,22 +188,23 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
color: Theme.of(context).colorScheme.primary,
icon: const Icon(Icons.close_outlined),
onPressed: () {
itController.isEditingSourceText
? itController.setIsEditingSourceText(false)
: itController.closeIT();
widget.choreographer.isEditingSourceText
? widget.choreographer
.setEditingSourceText(false)
: widget.choreographer.closeIT();
},
),
),
],
),
if (!itController.isEditingSourceText)
if (!widget.choreographer.isEditingSourceText)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: !itController.willOpen
child: !widget.choreographer.isITOpen
? const SizedBox()
: itController.sourceText != null
: widget.choreographer.sourceText != null
? Text(
itController.sourceText!,
widget.choreographer.sourceText!,
textAlign: TextAlign.center,
)
: const LinearProgressIndicator(),
@ -220,13 +214,15 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
padding: const EdgeInsets.symmetric(horizontal: 4.0),
constraints: const BoxConstraints(minHeight: 80),
child: AnimatedSize(
duration: itController.animationSpeed,
duration: const Duration(milliseconds: 300),
child: Center(
child: itController.choreographer.errorService.isError
? ITError(controller: itController)
: itController.isTranslationDone
child: widget.choreographer.errorService.isError
? ITError(choreographer: widget.choreographer)
: widget.choreographer.isITDone
? const SizedBox()
: ITChoices(controller: itController),
: ITChoices(
choreographer: widget.choreographer,
),
),
),
),
@ -242,39 +238,19 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
}
class ITChoices extends StatelessWidget {
final Choreographer choreographer;
const ITChoices({
super.key,
required this.controller,
required this.choreographer,
});
// final choices = [
// "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.",
// "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.",
// "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.",
// "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.",
// "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.",
// "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.",
// ];
final ITController controller;
String? get sourceText {
if ((controller.sourceText == null || controller.sourceText!.isEmpty)) {
ErrorHandler.logError(
m: "null source text in ItChoices",
data: {},
);
}
return controller.sourceText;
}
void showCard(
BuildContext context,
int index, [
Color? borderColor,
String? choiceFeedback,
]) {
if (controller.currentITStep == null) {
if (choreographer.itStepContinuances == null) {
ErrorHandler.logError(
m: "currentITStep is null in showCard",
s: StackTrace.current,
@ -285,41 +261,34 @@ class ITChoices extends StatelessWidget {
return;
}
controller.choreographer.chatController.inputFocus.unfocus();
final text = choreographer.itStepContinuances![index].text;
choreographer.chatController.inputFocus.unfocus();
MatrixState.pAnyState.closeOverlay("it_feedback_card");
OverlayUtil.showPositionedCard(
context: context,
cardToShow: choiceFeedback == null
? WordDataCard(
word: controller.currentITStep!.continuances[index].text,
wordLang: controller.targetLangCode,
fullText: sourceText ?? controller.choreographer.currentText,
fullTextLang: sourceText != null
? controller.sourceLangCode
: controller.targetLangCode,
// IMPORTANT COMMENT TO KEEP: We're going to forace hasInfo to false for now
// because we don't want to show the word data card for correct choices and the contextual definition
// for incorrect choices. This gives away the answer (if you're Kel at least).
// The reason hasInfo is false for incorrect choices is that we're not includng the tokens for distractors.
// Correct choices will have the tokens, but we don't want to show something different for them.
// hasInfo: controller.currentITStep!.continuances[index].hasInfo,
hasInfo: false,
word: text,
wordLang: choreographer.l2LangCode!,
fullText: choreographer.sourceText ?? choreographer.currentText,
fullTextLang: choreographer.sourceText != null
? choreographer.l1LangCode!
: choreographer.l2LangCode!,
choiceFeedback: choiceFeedback,
room: controller.choreographer.chatController.room,
)
: ITFeedbackCard(
req: FullTextTranslationRequestModel(
text: controller.currentITStep!.continuances[index].text,
tgtLang: controller.sourceLangCode,
userL1: controller.sourceLangCode,
userL2: controller.targetLangCode,
text: text,
tgtLang: choreographer.l2LangCode!,
userL1: choreographer.l1LangCode!,
userL2: choreographer.l2LangCode!,
),
choiceFeedback: choiceFeedback,
),
maxHeight: 300,
maxWidth: 300,
borderColor: borderColor,
transformTargetId: controller.choreographer.itBarTransformTargetKey,
transformTargetId: choreographer.itBarTransformTargetKey,
isScrollable: choiceFeedback == null,
overlayKey: "it_feedback_card",
ignorePointer: true,
@ -328,12 +297,41 @@ class ITChoices extends StatelessWidget {
void selectContinuance(int index, BuildContext context) {
MatrixState.pAnyState.closeOverlay("it_feedback_card");
final Continuance continuance =
controller.currentITStep!.continuances[index];
Continuance continuance;
try {
continuance = choreographer.onSelectContinuance(index);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
level: SentryLevel.warning,
data: {
"index": index,
},
);
choreographer.closeIT();
return;
}
if (continuance.level == 1) {
Future.delayed(
const Duration(milliseconds: 500),
() => controller.selectTranslation(index),
() {
try {
choreographer.onAcceptContinuance(index);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
level: SentryLevel.warning,
data: {
"index": index,
},
);
choreographer.closeIT();
return;
}
},
);
} else {
showCard(
@ -343,20 +341,16 @@ class ITChoices extends StatelessWidget {
continuance.feedbackText(context),
);
}
controller.currentITStep!.continuances[index] = continuance.copyWith(
wasClicked: true,
);
controller.choreographer.setState();
}
@override
Widget build(BuildContext context) {
try {
if (controller.isEditingSourceText) {
if (choreographer.isEditingSourceText) {
return const SizedBox();
}
if (controller.currentITStep == null) {
return controller.willOpen
if (choreographer.itStepContinuances == null) {
return choreographer.isITOpen
? CircularProgressIndicator(
strokeWidth: 2.0,
color: Theme.of(context).colorScheme.primary,
@ -364,11 +358,11 @@ class ITChoices extends StatelessWidget {
: const SizedBox();
}
return ChoicesArray(
id: controller.currentITStep.hashCode.toString(),
isLoading: controller.isLoading ||
controller.choreographer.isFetching ||
controller.currentITStep == null,
choices: controller.currentITStep!.continuances.map((e) {
id: Object.hashAll(choreographer.itStepContinuances!).toString(),
isLoading: choreographer.isFetching ||
choreographer.itStepContinuances == null,
choices: choreographer.itStepContinuances!.map((e) {
debugPrint("WAS CLICKED: ${e.wasClicked}");
try {
return Choice(
text: e.text.trim(),
@ -383,8 +377,8 @@ class ITChoices extends StatelessWidget {
onPressed: (value, index) => selectContinuance(index, context),
onLongPress: (value, index) => showCard(context, index),
selectedChoiceIndex: null,
langCode: controller.choreographer.pangeaController.languageController
.activeL2Code(),
langCode:
choreographer.pangeaController.languageController.activeL2Code(),
);
} catch (e) {
debugger(when: kDebugMode);
@ -394,8 +388,11 @@ class ITChoices extends StatelessWidget {
}
class ITError extends StatelessWidget {
final ITController controller;
const ITError({super.key, required this.controller});
final Choreographer choreographer;
const ITError({
super.key,
required this.choreographer,
});
@override
Widget build(BuildContext context) {
@ -413,10 +410,7 @@ class ITError extends StatelessWidget {
),
),
IconButton(
onPressed: () {
controller.closeIT();
controller.choreographer.errorService.resetError();
},
onPressed: choreographer.closeIT,
icon: const Icon(
Icons.close,
size: 20,

View file

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart';

View file

@ -1,59 +1,65 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import '../../../pages/chat/chat.dart';
class ChoreographerSendButton extends StatefulWidget {
class ChoreographerSendButton extends StatelessWidget {
const ChoreographerSendButton({
super.key,
required this.controller,
});
final ChatController controller;
@override
State<ChoreographerSendButton> createState() =>
ChoreographerSendButtonState();
}
class ChoreographerSendButtonState extends State<ChoreographerSendButton> {
StreamSubscription? _choreoSub;
@override
void initState() {
// Rebuild the widget each time there's an update from
// choreo. This keeps the spin up-to-date.
_choreoSub = widget.controller.choreographer.stateStream.stream.listen((_) {
setState(() {});
});
super.initState();
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
Future<void> _onPressed(BuildContext context) async {
controller.choreographer.onClickSend();
try {
await controller.choreographer.send();
} on ShowPaywallException {
PaywallCard.show(
context,
controller.choreographer.inputTransformTargetKey,
);
} on OpenMatchesException {
if (controller.choreographer.firstIGCMatch != null) {
OverlayUtil.showIGCMatch(
controller.choreographer.firstIGCMatch!,
controller.choreographer,
context,
);
}
}
}
@override
Widget build(BuildContext context) {
return Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
icon: const Icon(Icons.send_outlined),
color:
widget.controller.choreographer.assistanceState.stateColor(context),
onPressed: widget.controller.choreographer.isFetching
? null
: () {
widget.controller.choreographer.incrementTimesClicked();
widget.controller.choreographer.send(context);
},
tooltip: L10n.of(context).send,
),
return ListenableBuilder(
listenable: controller.choreographer,
builder: (context, _) {
return ValueListenableBuilder(
valueListenable: controller.choreographer.textController,
builder: (context, _, __) {
return Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
icon: const Icon(Icons.send_outlined),
color: controller.choreographer.assistanceState
.stateColor(context),
onPressed: controller.choreographer.isFetching
? null
: () => _onPressed(context),
tooltip: L10n.of(context).send,
),
);
},
);
},
);
}
}

View file

@ -5,8 +5,11 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart';
import '../../../pages/chat/chat.dart';
@ -27,7 +30,6 @@ class StartIGCButtonState extends State<StartIGCButton>
AssistanceState get assistanceState =>
widget.controller.choreographer.assistanceState;
AnimationController? _controller;
StreamSubscription? _choreoListener;
AssistanceState? _prevState;
@override
@ -36,19 +38,17 @@ class StartIGCButtonState extends State<StartIGCButton>
vsync: this,
duration: const Duration(seconds: 2),
);
_choreoListener = widget.controller.choreographer.stateStream.stream
.listen(_updateSpinnerState);
widget.controller.choreographer.addListener(_updateSpinnerState);
super.initState();
}
@override
void dispose() {
_controller?.dispose();
_choreoListener?.cancel();
super.dispose();
}
void _updateSpinnerState(_) {
void _updateSpinnerState() {
if (_prevState != AssistanceState.fetching &&
assistanceState == AssistanceState.fetching) {
_controller?.repeat();
@ -62,8 +62,14 @@ class StartIGCButtonState extends State<StartIGCButton>
}
void _showFirstMatch() {
if (widget.controller.choreographer.igc.canShowFirstMatch) {
widget.controller.choreographer.igc.showFirstMatch(context);
if (widget.controller.choreographer.canShowFirstIGCMatch) {
final match = widget.controller.choreographer.igc.onShowFirstMatch();
if (match == null) return;
OverlayUtil.showIGCMatch(
match,
widget.controller.choreographer,
context,
);
}
}
@ -79,7 +85,10 @@ class StartIGCButtonState extends State<StartIGCButton>
Future<void> _onTap() async {
switch (assistanceState) {
case AssistanceState.noSub:
await PaywallCard.show(context, widget.controller);
await PaywallCard.show(
context,
widget.controller.choreographer.inputTransformTargetKey,
);
return;
case AssistanceState.noMessage:
showDialog(
@ -92,8 +101,16 @@ class StartIGCButtonState extends State<StartIGCButton>
if (widget.controller.shouldShowLanguageMismatchPopup) {
widget.controller.showLanguageMismatchPopup();
} else {
await widget.controller.choreographer.getLanguageHelp(manual: true);
_showFirstMatch();
final igcMatch =
await widget.controller.choreographer.requestLanguageAssistance();
if (igcMatch != null) {
OverlayUtil.showIGCMatch(
igcMatch,
widget.controller.choreographer,
context,
);
}
}
return;
case AssistanceState.fetched:
@ -120,66 +137,70 @@ class StartIGCButtonState extends State<StartIGCButton>
@override
Widget build(BuildContext context) {
final icon = Icon(
size: 36,
Icons.autorenew_rounded,
color: assistanceState.stateColor(context),
);
return Tooltip(
message: _enableFeedback ? L10n.of(context).check : "",
child: Material(
elevation: _enableFeedback ? 4.0 : 0.0,
borderRadius: BorderRadius.circular(99.0),
shadowColor: Theme.of(context).colorScheme.surface.withAlpha(128),
child: InkWell(
enableFeedback: _enableFeedback,
onTap: _enableFeedback ? _onTap : null,
customBorder: const CircleBorder(),
onLongPress: _enableFeedback
? () => showDialog(
context: context,
builder: (c) => const SettingsLearning(),
barrierDismissible: false,
)
: null,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedContainer(
height: 40.0,
width: 40.0,
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor,
),
return ValueListenableBuilder(
valueListenable: widget.controller.choreographer.textController,
builder: (context, _, __) {
final icon = Icon(
size: 36,
Icons.autorenew_rounded,
color: assistanceState.stateColor(context),
);
return Tooltip(
message: _enableFeedback ? L10n.of(context).check : "",
child: Material(
elevation: _enableFeedback ? 4.0 : 0.0,
borderRadius: BorderRadius.circular(99.0),
shadowColor: Theme.of(context).colorScheme.surface.withAlpha(128),
child: InkWell(
enableFeedback: _enableFeedback,
onTap: _enableFeedback ? _onTap : null,
customBorder: const CircleBorder(),
onLongPress: _enableFeedback
? () => showDialog(
context: context,
builder: (c) => const SettingsLearning(),
barrierDismissible: false,
)
: null,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedContainer(
height: 40.0,
width: 40.0,
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor,
),
),
_controller != null
? RotationTransition(
turns: Tween(begin: 0.0, end: math.pi * 2)
.animate(_controller!),
child: icon,
)
: icon,
AnimatedContainer(
width: 20,
height: 20,
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor,
),
),
Icon(
size: 16,
Icons.check,
color: assistanceState.stateColor(context),
),
],
),
_controller != null
? RotationTransition(
turns: Tween(begin: 0.0, end: math.pi * 2)
.animate(_controller!),
child: icon,
)
: icon,
AnimatedContainer(
width: 20,
height: 20,
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor,
),
),
Icon(
size: 16,
Icons.check,
color: assistanceState.stateColor(context),
),
],
),
),
),
),
);
},
);
}
}

View file

@ -3,6 +3,10 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart';
import 'package:fluffychat/pangea/common/utils/any_state_holder.dart';
import 'package:fluffychat/pangea/common/widgets/anchored_overlay_widget.dart';
import 'package:fluffychat/pangea/common/widgets/overlay_container.dart';
@ -217,6 +221,28 @@ class OverlayUtil {
}
}
static void showIGCMatch(
PangeaMatchState match,
Choreographer choreographer,
BuildContext context,
) {
MatrixState.pAnyState.closeAllOverlays();
showPositionedCard(
overlayKey:
"span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}",
context: context,
cardToShow: SpanCard(
match: match,
choreographer: choreographer,
),
maxHeight: 325,
maxWidth: 325,
transformTargetId: choreographer.inputTransformTargetKey,
ignorePointer: true,
isScrollable: false,
);
}
static void showTutorialOverlay(
BuildContext context, {
required Widget overlayContent,

View file

@ -27,6 +27,7 @@ import 'package:fluffychat/pangea/subscription/utils/subscription_app_id.dart';
import 'package:fluffychat/pangea/subscription/widgets/subscription_paywall.dart';
import 'package:fluffychat/pangea/user/controllers/user_controller.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum SubscriptionStatus {
loading,
@ -267,6 +268,8 @@ class SubscriptionController extends BaseController {
return;
}
if (isSubscribed == null || isSubscribed!) return;
MatrixState.pAnyState.closeAllOverlays();
await showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: !PlatformInfos.isMobile,