refactor: expose text updates from it/igc via streams and respond to those streams in the choreographer

This commit is contained in:
ggurdin 2025-11-13 12:51:26 -05:00
parent fbd71ef988
commit 0ec17d615e
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
17 changed files with 1123 additions and 1311 deletions

View file

@ -32,6 +32,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_feedback.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
@ -424,7 +425,6 @@ class ChatController extends State<ChatPageWithRoom>
@override
void initState() {
inputFocus = FocusNode(onKeyEvent: _customEnterKeyHandling);
choreographer = Choreographer(inputFocus);
scrollController.addListener(_updateScrollController);
// #Pangea
@ -441,115 +441,111 @@ class ChatController extends State<ChatPageWithRoom>
sendingClient = Matrix.of(context).client;
readMarkerEventId = room.hasNewMessages ? room.fullyRead : '';
WidgetsBinding.instance.addObserver(this);
// #Pangea
if (!mounted) return;
Future.delayed(const Duration(seconds: 1), () async {
if (!mounted) return;
if (mounted) {
pangeaController.languageController.showDialogOnEmptyLanguage(
context,
() => Future.delayed(
Duration.zero,
() => setState(() {}),
),
);
}
});
_levelSubscription = pangeaController.getAnalytics.stateStream
.where(
(update) =>
update is Map<String, dynamic> &&
(update['level_up'] != null || update['unlocked_constructs'] != null),
)
.listen(
(update) {
if (update['level_up'] != null) {
LevelUpUtil.showLevelUpDialog(
update['upper_level'],
update['lower_level'],
context,
);
} else if (update['unlocked_constructs'] != null) {
ConstructNotificationUtil.addUnlockedConstruct(
List.from(update['unlocked_constructs']),
context,
);
}
},
);
_analyticsSubscription =
pangeaController.getAnalytics.analyticsStream.stream.listen((update) {
if (update.targetID == null) return;
OverlayUtil.showOverlay(
overlayKey: "${update.targetID ?? ""}_points",
followerAnchor: Alignment.bottomCenter,
targetAnchor: Alignment.bottomCenter,
context: context,
child: PointsGainedAnimation(
points: update.points,
targetID: update.targetID!,
),
transformTargetId: update.targetID ?? "",
closePrevOverlay: false,
backDropToDismiss: false,
ignorePointer: true,
);
});
_botAudioSubscription = room.client.onSync.stream
.where(
(update) => update.rooms?.join?[roomId]?.timeline?.events != null,
)
.listen((update) async {
final timeline = update.rooms!.join![roomId]!.timeline!;
final botAudioEvent = timeline.events!.firstWhereOrNull(
(e) =>
e.senderId == BotName.byEnvironment &&
e.content.tryGet<String>('msgtype') == MessageTypes.Audio &&
DateTime.now().difference(e.originServerTs) <
const Duration(seconds: 10),
);
if (botAudioEvent == null) return;
final matrix = Matrix.of(context);
matrix.voiceMessageEventId.value = botAudioEvent.eventId;
matrix.audioPlayer?.dispose();
matrix.audioPlayer = AudioPlayer();
final event = Event.fromMatrixEvent(botAudioEvent, room);
final audioFile = await event.getPangeaAudioFile();
if (audioFile == null) return;
if (!kIsWeb) {
final tempDir = await getTemporaryDirectory();
File? file;
file = File('${tempDir.path}/${audioFile.name}');
await file.writeAsBytes(audioFile.bytes);
matrix.audioPlayer!.setFilePath(file.path);
} else {
matrix.audioPlayer!.setAudioSource(
BytesAudioSource(
audioFile.bytes,
audioFile.mimeType,
),
);
}
matrix.audioPlayer!.play();
});
// Pangea#
_tryLoadTimeline();
if (kIsWeb) {
// #Pangea
onFocusSub?.cancel();
// Pangea#
onFocusSub = html.window.onFocus.listen((_) => setReadMarker());
}
// #Pangea
_pangeaInit();
// Pangea#
}
// #Pangea
void _onLevelUp(dynamic update) {
if (update['level_up'] != null) {
LevelUpUtil.showLevelUpDialog(
update['upper_level'],
update['lower_level'],
context,
);
} else if (update['unlocked_constructs'] != null) {
ConstructNotificationUtil.addUnlockedConstruct(
List.from(update['unlocked_constructs']),
context,
);
}
}
void _onAnalyticsUpdate(AnalyticsStreamUpdate update) {
if (update.targetID == null) return;
OverlayUtil.showOverlay(
overlayKey: "${update.targetID ?? ""}_points",
followerAnchor: Alignment.bottomCenter,
targetAnchor: Alignment.bottomCenter,
context: context,
child: PointsGainedAnimation(
points: update.points,
targetID: update.targetID!,
),
transformTargetId: update.targetID ?? "",
closePrevOverlay: false,
backDropToDismiss: false,
ignorePointer: true,
);
}
Future<void> _botAudioListener(SyncUpdate update) async {
if (update.rooms?.join?[roomId]?.timeline?.events == null) return;
final timeline = update.rooms!.join![roomId]!.timeline!;
final botAudioEvent = timeline.events!.firstWhereOrNull(
(e) =>
e.senderId == BotName.byEnvironment &&
e.content.tryGet<String>('msgtype') == MessageTypes.Audio &&
DateTime.now().difference(e.originServerTs) <
const Duration(seconds: 10),
);
if (botAudioEvent == null) return;
final matrix = Matrix.of(context);
matrix.voiceMessageEventId.value = botAudioEvent.eventId;
matrix.audioPlayer?.dispose();
matrix.audioPlayer = AudioPlayer();
final event = Event.fromMatrixEvent(botAudioEvent, room);
final audioFile = await event.getPangeaAudioFile();
if (audioFile == null) return;
if (!kIsWeb) {
final tempDir = await getTemporaryDirectory();
File? file;
file = File('${tempDir.path}/${audioFile.name}');
await file.writeAsBytes(audioFile.bytes);
matrix.audioPlayer!.setFilePath(file.path);
} else {
matrix.audioPlayer!.setAudioSource(
BytesAudioSource(
audioFile.bytes,
audioFile.mimeType,
),
);
}
matrix.audioPlayer!.play();
}
void _pangeaInit() {
choreographer = Choreographer(inputFocus);
_levelSubscription =
pangeaController.getAnalytics.stateStream.listen(_onLevelUp);
_analyticsSubscription = pangeaController
.getAnalytics.analyticsStream.stream
.listen(_onAnalyticsUpdate);
_botAudioSubscription = room.client.onSync.stream.listen(_botAudioListener);
Future.delayed(const Duration(seconds: 1), () async {
if (!mounted) return;
pangeaController.languageController.showDialogOnEmptyLanguage(
context,
() => () => setState(() {}),
);
});
}
// Pangea#
void _tryLoadTimeline() async {
final initialEventId = widget.eventId;
loadTimelineFuture = _getTimeline();
@ -781,7 +777,7 @@ class ChatController extends State<ChatPageWithRoom>
// inputFocus.removeListener(_inputFocusListener);
// Pangea#
onFocusSub?.cancel();
//#Pangea
// #Pangea
WidgetsBinding.instance.removeObserver(this);
_storeInputTimeoutTimer?.cancel();
_displayChatDetailsColumn.dispose();
@ -2188,14 +2184,15 @@ class ChatController extends State<ChatPageWithRoom>
}
void showNextMatch() {
final match = choreographer.igcController.firstOpenMatch;
MatrixState.pAnyState.closeOverlay();
final match = choreographer.igcController.openMatches.firstOrNull;
if (match == null) {
inputFocus.requestFocus();
return;
}
match.updatedMatch.isITStart
? choreographer.openIT(match)
? choreographer.itController.openIT(sendController.text)
: OverlayUtil.showIGCMatch(
match,
choreographer,
@ -2232,6 +2229,7 @@ class ChatController extends State<ChatPageWithRoom>
OverlayUtil.showPositionedCard(
context: context,
cardToShow: LanguageMismatchPopup(
overlayId: 'language_mismatch_popup',
onConfirm: () async {
await MatrixState.pangeaController.userController.updateProfile(
(profile) {
@ -2249,6 +2247,7 @@ class ChatController extends State<ChatPageWithRoom>
maxHeight: 325,
maxWidth: 325,
transformTargetId: ChoreoConstants.inputTransformTargetKey,
overlayKey: 'language_mismatch_popup',
);
}

View file

@ -1,401 +1,394 @@
import 'package:flutter/material.dart';
// import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:matrix/matrix.dart';
// import 'package:animations/animations.dart';
// import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/other_party_can_receive.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../config/themes.dart';
import 'chat.dart';
import 'input_bar.dart';
// import 'package:fluffychat/config/app_config.dart';
// import 'package:fluffychat/l10n/l10n.dart';
// import 'package:fluffychat/utils/other_party_can_receive.dart';
// import 'package:fluffychat/utils/platform_infos.dart';
// import 'package:fluffychat/widgets/avatar.dart';
// import 'package:fluffychat/widgets/matrix.dart';
// import '../../config/themes.dart';
// import 'chat.dart';
// import 'input_bar.dart';
class ChatInputRow extends StatelessWidget {
final ChatController controller;
// class ChatInputRow extends StatelessWidget {
// final ChatController controller;
const ChatInputRow(this.controller, {super.key});
// const ChatInputRow(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// @override
// Widget build(BuildContext context) {
// final theme = Theme.of(context);
const height = 48.0;
// const height = 48.0;
if (!controller.room.otherPartyCanReceiveMessages) {
return Center(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
L10n.of(context).otherPartyNotLoggedIn,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
);
}
// if (!controller.room.otherPartyCanReceiveMessages) {
// return Center(
// child: Padding(
// padding: const EdgeInsets.all(12.0),
// child: Text(
// L10n.of(context).otherPartyNotLoggedIn,
// style: theme.textTheme.bodySmall,
// textAlign: TextAlign.center,
// ),
// ),
// );
// }
final selectedTextButtonStyle = TextButton.styleFrom(
foregroundColor: theme.colorScheme.onTertiaryContainer,
);
// final selectedTextButtonStyle = TextButton.styleFrom(
// foregroundColor: theme.colorScheme.onTertiaryContainer,
// );
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: controller.selectMode
? <Widget>[
if (controller.selectedEvents
.every((event) => event.status == EventStatus.error))
SizedBox(
height: height,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.orange,
),
onPressed: controller.deleteErrorEventsAction,
child: Row(
children: <Widget>[
const Icon(Icons.delete),
Text(L10n.of(context).delete),
],
),
),
)
else
SizedBox(
height: height,
child: TextButton(
style: selectedTextButtonStyle,
onPressed: controller.forwardEventsAction,
child: Row(
children: <Widget>[
const Icon(Icons.keyboard_arrow_left_outlined),
Text(L10n.of(context).forward),
],
),
),
),
controller.selectedEvents.length == 1
? controller.selectedEvents.first
.getDisplayEvent(controller.timeline!)
.status
.isSent
? SizedBox(
height: height,
child: TextButton(
style: selectedTextButtonStyle,
onPressed: controller.replyAction,
child: Row(
children: <Widget>[
Text(L10n.of(context).reply),
const Icon(Icons.keyboard_arrow_right),
],
),
),
)
: SizedBox(
height: height,
child: TextButton(
style: selectedTextButtonStyle,
onPressed: controller.sendAgainAction,
child: Row(
children: <Widget>[
Text(L10n.of(context).tryToSendAgain),
const SizedBox(width: 4),
const Icon(Icons.send_outlined, size: 16),
],
),
),
)
: const SizedBox.shrink(),
]
: <Widget>[
const SizedBox(width: 4),
AnimatedContainer(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
width: controller.sendController.text.isNotEmpty ? 0 : height,
height: height,
alignment: Alignment.center,
decoration: const BoxDecoration(),
clipBehavior: Clip.hardEdge,
child: PopupMenuButton<String>(
useRootNavigator: true,
icon: const Icon(Icons.add_circle_outline),
iconColor: theme.colorScheme.onPrimaryContainer,
onSelected: controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'location',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.gps_fixed_outlined),
),
title: Text(L10n.of(context).shareLocation),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'image',
child: ListTile(
leading: CircleAvatar(
backgroundColor: theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.photo_outlined),
),
title: Text(L10n.of(context).sendImage),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'video',
child: ListTile(
leading: CircleAvatar(
backgroundColor: theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.video_camera_back_outlined),
),
title: Text(L10n.of(context).sendVideo),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'file',
child: ListTile(
leading: CircleAvatar(
backgroundColor: theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.attachment_outlined),
),
title: Text(L10n.of(context).sendFile),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
),
if (PlatformInfos.isMobile)
AnimatedContainer(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
width: controller.sendController.text.isNotEmpty ? 0 : height,
height: height,
alignment: Alignment.center,
decoration: const BoxDecoration(),
clipBehavior: Clip.hardEdge,
child: PopupMenuButton(
useRootNavigator: true,
icon: const Icon(Icons.camera_alt_outlined),
onSelected: controller.onAddPopupMenuButtonSelected,
iconColor: theme.colorScheme.onPrimaryContainer,
itemBuilder: (context) => [
PopupMenuItem<String>(
value: 'camera-video',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.videocam_outlined),
),
title: Text(L10n.of(context).recordAVideo),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'camera',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.camera_alt_outlined),
),
title: Text(L10n.of(context).takeAPhoto),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
),
Container(
height: height,
width: height,
alignment: Alignment.center,
child: IconButton(
tooltip: L10n.of(context).emojis,
color: theme.colorScheme.onPrimaryContainer,
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,
),
),
if (Matrix.of(context).isMultiAccount &&
Matrix.of(context).hasComplexBundles &&
Matrix.of(context).currentBundle!.length > 1)
Container(
width: height,
height: height,
alignment: Alignment.center,
child: _ChatAccountPicker(controller),
),
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,
// #Pangea
// onSubmitted: controller.onInputBarSubmitted,
onSubmitted: (_) => controller.onInputBarSubmitted(),
// Pangea#
onSubmitImage: controller.sendImageFromClipBoard,
focusNode: controller.inputFocus,
controller: controller.sendController,
decoration: InputDecoration(
contentPadding: const EdgeInsets.only(
left: 6.0,
right: 6.0,
bottom: 6.0,
top: 3.0,
),
hintText: L10n.of(context).writeAMessage,
hintMaxLines: 1,
border: InputBorder.none,
enabledBorder: InputBorder.none,
filled: false,
),
onChanged: controller.onInputBarChanged,
// #Pangea
choreographer: controller.choreographer,
showNextMatch: controller.showNextMatch,
// Pangea#
),
),
),
Container(
height: height,
width: height,
alignment: Alignment.center,
child: PlatformInfos.platformCanRecord &&
controller.sendController.text.isEmpty
? 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),
)
: FloatingActionButton.small(
tooltip: L10n.of(context).send,
onPressed: controller.send,
elevation: 0,
heroTag: null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(height),
),
backgroundColor: theme.bubbleColor,
foregroundColor: theme.onBubbleColor,
child: const Icon(Icons.send_outlined),
),
),
],
);
}
}
// return Row(
// crossAxisAlignment: CrossAxisAlignment.end,
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// children: controller.selectMode
// ? <Widget>[
// if (controller.selectedEvents
// .every((event) => event.status == EventStatus.error))
// SizedBox(
// height: height,
// child: TextButton(
// style: TextButton.styleFrom(
// foregroundColor: Colors.orange,
// ),
// onPressed: controller.deleteErrorEventsAction,
// child: Row(
// children: <Widget>[
// const Icon(Icons.delete),
// Text(L10n.of(context).delete),
// ],
// ),
// ),
// )
// else
// SizedBox(
// height: height,
// child: TextButton(
// style: selectedTextButtonStyle,
// onPressed: controller.forwardEventsAction,
// child: Row(
// children: <Widget>[
// const Icon(Icons.keyboard_arrow_left_outlined),
// Text(L10n.of(context).forward),
// ],
// ),
// ),
// ),
// controller.selectedEvents.length == 1
// ? controller.selectedEvents.first
// .getDisplayEvent(controller.timeline!)
// .status
// .isSent
// ? SizedBox(
// height: height,
// child: TextButton(
// style: selectedTextButtonStyle,
// onPressed: controller.replyAction,
// child: Row(
// children: <Widget>[
// Text(L10n.of(context).reply),
// const Icon(Icons.keyboard_arrow_right),
// ],
// ),
// ),
// )
// : SizedBox(
// height: height,
// child: TextButton(
// style: selectedTextButtonStyle,
// onPressed: controller.sendAgainAction,
// child: Row(
// children: <Widget>[
// Text(L10n.of(context).tryToSendAgain),
// const SizedBox(width: 4),
// const Icon(Icons.send_outlined, size: 16),
// ],
// ),
// ),
// )
// : const SizedBox.shrink(),
// ]
// : <Widget>[
// const SizedBox(width: 4),
// AnimatedContainer(
// duration: FluffyThemes.animationDuration,
// curve: FluffyThemes.animationCurve,
// width: controller.sendController.text.isNotEmpty ? 0 : height,
// height: height,
// alignment: Alignment.center,
// decoration: const BoxDecoration(),
// clipBehavior: Clip.hardEdge,
// child: PopupMenuButton<String>(
// useRootNavigator: true,
// icon: const Icon(Icons.add_circle_outline),
// iconColor: theme.colorScheme.onPrimaryContainer,
// onSelected: controller.onAddPopupMenuButtonSelected,
// itemBuilder: (BuildContext context) =>
// <PopupMenuEntry<String>>[
// if (PlatformInfos.isMobile)
// PopupMenuItem<String>(
// value: 'location',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor:
// theme.colorScheme.onPrimaryContainer,
// foregroundColor: theme.colorScheme.primaryContainer,
// child: const Icon(Icons.gps_fixed_outlined),
// ),
// title: Text(L10n.of(context).shareLocation),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// PopupMenuItem<String>(
// value: 'image',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor: theme.colorScheme.onPrimaryContainer,
// foregroundColor: theme.colorScheme.primaryContainer,
// child: const Icon(Icons.photo_outlined),
// ),
// title: Text(L10n.of(context).sendImage),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// PopupMenuItem<String>(
// value: 'video',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor: theme.colorScheme.onPrimaryContainer,
// foregroundColor: theme.colorScheme.primaryContainer,
// child: const Icon(Icons.video_camera_back_outlined),
// ),
// title: Text(L10n.of(context).sendVideo),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// PopupMenuItem<String>(
// value: 'file',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor: theme.colorScheme.onPrimaryContainer,
// foregroundColor: theme.colorScheme.primaryContainer,
// child: const Icon(Icons.attachment_outlined),
// ),
// title: Text(L10n.of(context).sendFile),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// ],
// ),
// ),
// if (PlatformInfos.isMobile)
// AnimatedContainer(
// duration: FluffyThemes.animationDuration,
// curve: FluffyThemes.animationCurve,
// width: controller.sendController.text.isNotEmpty ? 0 : height,
// height: height,
// alignment: Alignment.center,
// decoration: const BoxDecoration(),
// clipBehavior: Clip.hardEdge,
// child: PopupMenuButton(
// useRootNavigator: true,
// icon: const Icon(Icons.camera_alt_outlined),
// onSelected: controller.onAddPopupMenuButtonSelected,
// iconColor: theme.colorScheme.onPrimaryContainer,
// itemBuilder: (context) => [
// PopupMenuItem<String>(
// value: 'camera-video',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor:
// theme.colorScheme.onPrimaryContainer,
// foregroundColor: theme.colorScheme.primaryContainer,
// child: const Icon(Icons.videocam_outlined),
// ),
// title: Text(L10n.of(context).recordAVideo),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// PopupMenuItem<String>(
// value: 'camera',
// child: ListTile(
// leading: CircleAvatar(
// backgroundColor:
// theme.colorScheme.onPrimaryContainer,
// foregroundColor: theme.colorScheme.primaryContainer,
// child: const Icon(Icons.camera_alt_outlined),
// ),
// title: Text(L10n.of(context).takeAPhoto),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// ],
// ),
// ),
// Container(
// height: height,
// width: height,
// alignment: Alignment.center,
// child: IconButton(
// tooltip: L10n.of(context).emojis,
// color: theme.colorScheme.onPrimaryContainer,
// 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,
// ),
// ),
// if (Matrix.of(context).isMultiAccount &&
// Matrix.of(context).hasComplexBundles &&
// Matrix.of(context).currentBundle!.length > 1)
// Container(
// width: height,
// height: height,
// alignment: Alignment.center,
// child: _ChatAccountPicker(controller),
// ),
// 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: InputDecoration(
// contentPadding: const EdgeInsets.only(
// left: 6.0,
// right: 6.0,
// bottom: 6.0,
// top: 3.0,
// ),
// hintText: L10n.of(context).writeAMessage,
// hintMaxLines: 1,
// border: InputBorder.none,
// enabledBorder: InputBorder.none,
// filled: false,
// ),
// onChanged: controller.onInputBarChanged,
// ),
// ),
// ),
// Container(
// height: height,
// width: height,
// alignment: Alignment.center,
// child: PlatformInfos.platformCanRecord &&
// controller.sendController.text.isEmpty
// ? 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),
// )
// : FloatingActionButton.small(
// tooltip: L10n.of(context).send,
// onPressed: controller.send,
// elevation: 0,
// heroTag: null,
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(height),
// ),
// backgroundColor: theme.bubbleColor,
// foregroundColor: theme.onBubbleColor,
// child: const Icon(Icons.send_outlined),
// ),
// ),
// ],
// );
// }
// }
class _ChatAccountPicker extends StatelessWidget {
final ChatController controller;
// class _ChatAccountPicker extends StatelessWidget {
// final ChatController controller;
const _ChatAccountPicker(this.controller);
// const _ChatAccountPicker(this.controller);
void _popupMenuButtonSelected(String mxid, BuildContext context) {
final client = Matrix.of(context)
.currentBundle!
.firstWhere((cl) => cl!.userID == mxid, orElse: () => null);
if (client == null) {
Logs().w('Attempted to switch to a non-existing client $mxid');
return;
}
controller.setSendingClient(client);
}
// void _popupMenuButtonSelected(String mxid, BuildContext context) {
// final client = Matrix.of(context)
// .currentBundle!
// .firstWhere((cl) => cl!.userID == mxid, orElse: () => null);
// if (client == null) {
// Logs().w('Attempted to switch to a non-existing client $mxid');
// return;
// }
// controller.setSendingClient(client);
// }
@override
Widget build(BuildContext context) {
final clients = controller.currentRoomBundle;
return Padding(
padding: const EdgeInsets.all(8.0),
child: FutureBuilder<Profile>(
future: controller.sendingClient.fetchOwnProfile(),
builder: (context, snapshot) => PopupMenuButton<String>(
useRootNavigator: true,
onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
itemBuilder: (BuildContext context) => clients
.map(
(client) => PopupMenuItem<String>(
value: client!.userID,
child: FutureBuilder<Profile>(
future: client.fetchOwnProfile(),
builder: (context, snapshot) => ListTile(
leading: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
client.userID!.localpart,
size: 20,
),
title: Text(snapshot.data?.displayName ?? client.userID!),
contentPadding: const EdgeInsets.all(0),
),
),
),
)
.toList(),
child: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
Matrix.of(context).client.userID!.localpart,
size: 20,
),
),
),
);
}
}
// @override
// Widget build(BuildContext context) {
// final clients = controller.currentRoomBundle;
// return Padding(
// padding: const EdgeInsets.all(8.0),
// child: FutureBuilder<Profile>(
// future: controller.sendingClient.fetchOwnProfile(),
// builder: (context, snapshot) => PopupMenuButton<String>(
// useRootNavigator: true,
// onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
// itemBuilder: (BuildContext context) => clients
// .map(
// (client) => PopupMenuItem<String>(
// value: client!.userID,
// child: FutureBuilder<Profile>(
// future: client.fetchOwnProfile(),
// builder: (context, snapshot) => ListTile(
// leading: Avatar(
// mxContent: snapshot.data?.avatarUrl,
// name: snapshot.data?.displayName ??
// client.userID!.localpart,
// size: 20,
// ),
// title: Text(snapshot.data?.displayName ?? client.userID!),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// ),
// )
// .toList(),
// child: Avatar(
// mxContent: snapshot.data?.avatarUrl,
// name: snapshot.data?.displayName ??
// Matrix.of(context).client.userID!.localpart,
// size: 20,
// ),
// ),
// ),
// );
// }
// }

View file

@ -1,16 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:emojis/emoji.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:matrix/matrix.dart';
import 'package:slugify/slugify.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
@ -37,7 +36,7 @@ class InputBar extends StatelessWidget {
final Choreographer choreographer;
final VoidCallback showNextMatch;
// Pangea#
final InputDecoration? decoration;
final InputDecoration decoration;
final ValueChanged<String>? onChanged;
final bool? autofocus;
final bool readOnly;
@ -51,7 +50,7 @@ class InputBar extends StatelessWidget {
this.onSubmitImage,
this.focusNode,
this.controller,
this.decoration,
required this.decoration,
this.onChanged,
this.autofocus,
this.textInputAction,
@ -63,14 +62,12 @@ class InputBar extends StatelessWidget {
super.key,
});
List<Map<String, String?>> getSuggestions(String text) {
if (controller!.selection.baseOffset !=
controller!.selection.extentOffset ||
controller!.selection.baseOffset < 0) {
List<Map<String, String?>> getSuggestions(TextEditingValue text) {
if (text.selection.baseOffset != text.selection.extentOffset ||
text.selection.baseOffset < 0) {
return []; // no entries if there is selected text
}
final searchText =
controller!.text.substring(0, controller!.selection.baseOffset);
final searchText = text.text.substring(0, text.selection.baseOffset);
final ret = <Map<String, String?>>[];
const maxResults = 30;
@ -237,36 +234,28 @@ class InputBar extends StatelessWidget {
Widget buildSuggestion(
BuildContext context,
Map<String, String?> suggestion,
void Function(Map<String, String?>) onSelected,
Client? client,
) {
final theme = Theme.of(context);
const size = 30.0;
// #Pangea
// const padding = EdgeInsets.all(4.0);
const padding = EdgeInsets.all(8.0);
// Pangea#
if (suggestion['type'] == 'command') {
final command = suggestion['name']!;
final hint = commandHint(L10n.of(context), command);
return Tooltip(
message: hint,
waitDuration: const Duration(days: 1), // don't show on hover
child: Container(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
commandExample(command),
style: const TextStyle(fontFamily: 'RobotoMono'),
),
Text(
hint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
child: ListTile(
onTap: () => onSelected(suggestion),
title: Text(
commandExample(command),
style: const TextStyle(fontFamily: 'RobotoMono'),
),
subtitle: Text(
hint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
),
);
@ -276,29 +265,28 @@ class InputBar extends StatelessWidget {
return Tooltip(
message: label,
waitDuration: const Duration(days: 1), // don't show on hover
child: Container(
padding: padding,
child: Text(label, style: const TextStyle(fontFamily: 'RobotoMono')),
child: ListTile(
onTap: () => onSelected(suggestion),
title: Text(label, style: const TextStyle(fontFamily: 'RobotoMono')),
),
);
}
if (suggestion['type'] == 'emote') {
return Container(
padding: padding,
child: Row(
return ListTile(
onTap: () => onSelected(suggestion),
leading: MxcImage(
// ensure proper ordering ...
key: ValueKey(suggestion['name']),
uri: suggestion['mxc'] is String
? Uri.parse(suggestion['mxc'] ?? '')
: null,
width: size,
height: size,
isThumbnail: false,
),
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
MxcImage(
// ensure proper ordering ...
key: ValueKey(suggestion['name']),
uri: suggestion['mxc'] is String
? Uri.parse(suggestion['mxc'] ?? '')
: null,
width: size,
height: size,
isThumbnail: false,
),
const SizedBox(width: 6),
Text(suggestion['name']!),
Expanded(
child: Align(
@ -324,39 +312,30 @@ class InputBar extends StatelessWidget {
}
if (suggestion['type'] == 'user' || suggestion['type'] == 'room') {
final url = Uri.parse(suggestion['avatar_url'] ?? '');
return Container(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Avatar(
mxContent: url,
name: suggestion.tryGet<String>('displayname') ??
suggestion.tryGet<String>('mxid'),
size: size,
client: client,
// #Pangea
userId: suggestion.tryGet<String>('mxid'),
// Pangea#
),
const SizedBox(width: 6),
// #Pangea
// Text(suggestion['displayname'] ?? suggestion['mxid']!),
Flexible(
child: Text(
suggestion['displayname'] ?? suggestion['mxid']!,
overflow: TextOverflow.ellipsis,
),
),
// Pangea#
],
return ListTile(
onTap: () => onSelected(suggestion),
leading: Avatar(
mxContent: url,
name: suggestion.tryGet<String>('displayname') ??
suggestion.tryGet<String>('mxid'),
size: size,
client: client,
),
// #Pangea
// title: Text(suggestion['displayname'] ?? suggestion['mxid']!),
title: Flexible(
child: Text(
suggestion['displayname'] ?? suggestion['mxid']!,
overflow: TextOverflow.ellipsis,
),
),
// Pangea#
);
}
return const SizedBox.shrink();
}
void insertSuggestion(_, Map<String, String?> suggestion) {
String insertSuggestion(Map<String, String?> suggestion) {
final replaceText =
controller!.text.substring(0, controller!.selection.baseOffset);
var startText = '';
@ -420,13 +399,8 @@ class InputBar extends StatelessWidget {
(Match m) => '${m[1]}$insertText',
);
}
if (insertText.isNotEmpty && startText.isNotEmpty) {
controller!.text = startText + afterText;
controller!.selection = TextSelection(
baseOffset: startText.length,
extentOffset: startText.length,
);
}
return startText + afterText;
}
// #Pangea
@ -446,15 +420,13 @@ class InputBar extends StatelessWidget {
void _onInputTap(BuildContext context) {
if (_shouldShowPaywall(context)) return;
final baseOffset = controller?.selection.baseOffset;
if (baseOffset == null) return;
final baseOffset = controller!.selection.baseOffset;
final adjustedOffset = _adjustOffsetForNormalization(baseOffset);
final match = choreographer.igcController.getMatchByOffset(adjustedOffset);
if (match == null) return;
if (match.updatedMatch.isITStart) {
choreographer.openIT(match);
choreographer.itController.openIT(controller!.text);
} else {
OverlayUtil.showIGCMatch(
match,
@ -462,6 +434,15 @@ class InputBar extends StatelessWidget {
context,
showNextMatch,
);
// rebuild the text field to highlight the newly selected match
choreographer.textController.setSystemText(
choreographer.textController.text,
EditTypeEnum.other,
);
choreographer.textController.selection = TextSelection.collapsed(
offset: baseOffset,
);
}
}
@ -476,7 +457,6 @@ class InputBar extends StatelessWidget {
int _adjustOffsetForNormalization(int baseOffset) {
int adjustedOffset = baseOffset;
final corrections = choreographer.igcController.recentAutomaticCorrections;
if (corrections == null) return adjustedOffset;
for (final correction in corrections) {
final match = correction.updatedMatch.match;
@ -490,176 +470,102 @@ class InputBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
// #Pangea
return ValueListenableBuilder(
valueListenable: choreographer.textController,
builder: (context, _, __) {
final enableAutocorrect = MatrixState.pangeaController.userController
.profile.toolSettings.enableAutocorrect;
return TypeAheadField<Map<String, String?>>(
direction: VerticalDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
controller: controller,
focusNode: focusNode,
hideOnSelect: false,
debounceDuration: const Duration(milliseconds: 50),
builder: (context, _, focusNode) {
final textField = TextField(
enableSuggestions: enableAutocorrect,
readOnly: controller!.choreographer.isRunningIT,
autocorrect: enableAutocorrect,
controller: controller,
focusNode: focusNode,
contextMenuBuilder: (c, e) => markdownContextBuilder(
c,
e,
_,
),
contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
final data = content.data;
if (data == null) return;
final theme = Theme.of(context);
return Autocomplete<Map<String, String?>>(
focusNode: focusNode,
textEditingController: controller,
optionsBuilder: getSuggestions,
fieldViewBuilder: (context, __, focusNode, _) => TextField(
controller: controller,
focusNode: focusNode,
readOnly: readOnly,
// #Pangea
// contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller),
contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, __),
onTap: () => _onInputTap(context),
// Pangea#
contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
final data = content.data;
if (data == null) return;
final file = MatrixFile(
mimeType: content.mimeType,
bytes: data,
name: content.uri.split('/').last,
);
room.sendFileEvent(
file,
shrinkImageMaxDimension: 1600,
);
},
),
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType!,
textInputAction: textInputAction,
autofocus: autofocus!,
inputFormatters: [
//LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
//setting max character count to 1000
//after max, nothing else can be typed
LengthLimitingTextInputFormatter(1000),
],
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted!(text);
},
style: controller?.exceededMaxLength ?? false
? const TextStyle(color: Colors.red)
: null,
onTap: () => _onInputTap(context),
decoration: decoration!,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged!(text);
},
textCapitalization: TextCapitalization.sentences,
final file = MatrixFile(
mimeType: content.mimeType,
bytes: data,
name: content.uri.split('/').last,
);
return Stack(
alignment: Alignment.centerLeft,
children: [
if (controller != null && controller!.text.isEmpty)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ShrinkableText(
text: controller!.choreographer.itController.open.value
? L10n.of(context).buildTranslation
: _defaultHintText(context),
maxWidth: double.infinity,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).disabledColor,
),
),
),
kIsWeb ? SelectionArea(child: textField) : textField,
],
room.sendFileEvent(
file,
shrinkImageMaxDimension: 1600,
);
},
suggestionsCallback: getSuggestions,
itemBuilder: (c, s) =>
buildSuggestion(c, s, Matrix.of(context).client),
onSelected: (Map<String, String?> suggestion) =>
insertSuggestion(context, suggestion),
errorBuilder: (BuildContext context, Object? error) =>
const SizedBox.shrink(),
loadingBuilder: (BuildContext context) => const SizedBox.shrink(),
// fix loading briefly flickering a dark box
emptyBuilder: (BuildContext context) => const SizedBox
.shrink(), // fix loading briefly showing no suggestions
),
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType!,
textInputAction: textInputAction,
autofocus: autofocus!,
inputFormatters: [
// #Pangea
//LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
//setting max character count to 1000
//after max, nothing else can be typed
LengthLimitingTextInputFormatter(1000),
// Pangea#
],
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted!(text);
},
// #Pangea
// maxLength: AppSettings.textMessageMaxLength.value,
// decoration: decoration!,
// Pangea#
decoration: decoration.copyWith(
hint: ValueListenableBuilder(
valueListenable: choreographer.itController.open,
builder: (context, _, __) {
return ShrinkableText(
text: choreographer.itController.open.value
? L10n.of(context).buildTranslation
: _defaultHintText(context),
maxWidth: double.infinity,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).disabledColor,
),
);
},
),
),
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged!(text);
},
textCapitalization: TextCapitalization.sentences,
),
optionsViewBuilder: (c, onSelected, s) {
final suggestions = s.toList();
return Material(
elevation: theme.appBarTheme.scrolledUnderElevation ?? 4,
shadowColor: theme.appBarTheme.shadowColor,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListView.builder(
shrinkWrap: true,
itemCount: suggestions.length,
itemBuilder: (context, i) => buildSuggestion(
c,
suggestions[i],
onSelected,
Matrix.of(context).client,
),
),
);
},
displayStringForOption: insertSuggestion,
optionsViewOpenDirection: OptionsViewOpenDirection.up,
);
// return TypeAheadField<Map<String, String?>>(
// direction: VerticalDirection.up,
// hideOnEmpty: true,
// hideOnLoading: true,
// controller: controller,
// focusNode: focusNode,
// hideOnSelect: false,
// debounceDuration: const Duration(milliseconds: 50),
// // show suggestions after 50ms idle time (default is 300)
// builder: (context, controller, focusNode) => TextField(
// controller: controller,
// focusNode: focusNode,
// readOnly: readOnly,
// contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller),
// contentInsertionConfiguration: ContentInsertionConfiguration(
// onContentInserted: (KeyboardInsertedContent content) {
// final data = content.data;
// if (data == null) return;
// final file = MatrixFile(
// mimeType: content.mimeType,
// bytes: data,
// name: content.uri.split('/').last,
// );
// room.sendFileEvent(
// file,
// shrinkImageMaxDimension: 1600,
// );
// },
// ),
// minLines: minLines,
// maxLines: maxLines,
// keyboardType: keyboardType!,
// textInputAction: textInputAction,
// autofocus: autofocus!,
// inputFormatters: [
// LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
// ],
// onSubmitted: (text) {
// // fix for library for now
// // it sets the types for the callback incorrectly
// onSubmitted!(text);
// },
// maxLength:
// AppSettings.textMessageMaxLength.getItem(Matrix.of(context).store),
// decoration: decoration,
// onChanged: (text) {
// // fix for the library for now
// // it sets the types for the callback incorrectly
// onChanged!(text);
// },
// textCapitalization: TextCapitalization.sentences,
// ),
// suggestionsCallback: getSuggestions,
// itemBuilder: (c, s) => buildSuggestion(c, s, Matrix.of(context).client),
// onSelected: (Map<String, String?> suggestion) =>
// insertSuggestion(context, suggestion),
// errorBuilder: (BuildContext context, Object? error) =>
// const SizedBox.shrink(),
// loadingBuilder: (BuildContext context) => const SizedBox.shrink(),
// // fix loading briefly flickering a dark box
// emptyBuilder: (BuildContext context) =>
// const SizedBox.shrink(), // fix loading briefly showing no suggestions
// );
// Pangea#
}
}

View file

@ -210,7 +210,10 @@ class PangeaChatInputRow extends StatelessWidget {
),
),
StartIGCButton(
controller: controller,
key: ValueKey(controller.choreographer),
onPressed: () =>
controller.onRequestWritingAssistance(manual: true),
choreographer: controller.choreographer,
initialState: state,
initialForegroundColor: state.stateColor(context),
initialBackgroundColor: state.backgroundColor(context),

View file

@ -10,10 +10,10 @@ import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.da
import 'package:fluffychat/pangea/choreographer/igc/igc_controller.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/it/completed_it_step_model.dart';
import 'package:fluffychat/pangea/choreographer/pangea_message_content_model.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.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';
@ -43,8 +43,10 @@ class Choreographer extends ChangeNotifier {
String? _lastChecked;
ChoreoModeEnum _choreoMode = ChoreoModeEnum.igc;
StreamSubscription? _languageStream;
StreamSubscription? _settingsUpdateStream;
StreamSubscription? _languageSub;
StreamSubscription? _settingsUpdateSub;
StreamSubscription? _acceptedContinuanceSub;
StreamSubscription? _updatedMatchSub;
Choreographer(
this.inputFocus,
@ -73,23 +75,30 @@ class Choreographer extends ChangeNotifier {
itController = ITController(
(e) => errorService.setErrorAndLock(ChoreoError(raw: e)),
);
itController.open.addListener(_onCloseIT);
itController.open.addListener(_onUpdateITOpenStatus);
itController.editing.addListener(_onSubmitSourceTextEdits);
igcController = IgcController(
(e) => errorService.setErrorAndLock(ChoreoError(raw: e)),
);
_languageStream ??= MatrixState
_languageSub ??= MatrixState
.pangeaController.userController.languageStream.stream
.listen((update) {
clear();
});
_settingsUpdateStream ??= MatrixState
_settingsUpdateSub ??= MatrixState
.pangeaController.userController.settingsUpdateStream.stream
.listen((_) {
notifyListeners();
});
_acceptedContinuanceSub ??= itController.acceptedContinuanceStream.stream
.listen(_onAcceptContinuance);
_updatedMatchSub ??=
igcController.matchUpdateStream.stream.listen(_onUpdateMatch);
}
void clear() {
@ -97,7 +106,7 @@ class Choreographer extends ChangeNotifier {
_timesClicked = 0;
_isFetching.value = false;
_choreoRecord = null;
itController.clear();
itController.closeIT();
itController.clearSourceText();
igcController.clear();
_resetDebounceTimer();
@ -108,14 +117,21 @@ class Choreographer extends ChangeNotifier {
void dispose() {
errorService.removeListener(notifyListeners);
itController.open.removeListener(_onCloseIT);
itController.editing.removeListener(_onSubmitSourceTextEdits);
textController.removeListener(_onChange);
_languageSub?.cancel();
_settingsUpdateSub?.cancel();
_acceptedContinuanceSub?.cancel();
_updatedMatchSub?.cancel();
_debounceTimer?.cancel();
igcController.dispose();
itController.dispose();
errorService.dispose();
textController.dispose();
_languageStream?.cancel();
_settingsUpdateStream?.cancel();
_debounceTimer?.cancel();
_isFetching.dispose();
TtsController.stop();
super.dispose();
}
@ -174,7 +190,7 @@ class Choreographer extends ChangeNotifier {
_lastChecked = textController.text;
if (errorService.isError) return;
if (textController.editType == EditTypeEnum.keyboard) {
if (igcController.hasIGCTextData ||
if (igcController.currentText != null ||
itController.sourceText.value != null) {
igcController.clear();
itController.clearSourceText();
@ -184,16 +200,13 @@ class Choreographer extends ChangeNotifier {
_resetDebounceTimer();
_debounceTimer ??= Timer(
const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart),
() => _getWritingAssistance(),
() => requestWritingAssistance(),
);
}
textController.editType = EditTypeEnum.keyboard;
}
Future<void> requestWritingAssistance({bool manual = false}) =>
_getWritingAssistance(manual: manual);
Future<void> _getWritingAssistance({
Future<void> requestWritingAssistance({
bool manual = false,
}) async {
if (assistanceState != AssistanceStateEnum.notFetched) return;
@ -213,19 +226,27 @@ class Choreographer extends ChangeNotifier {
_resetDebounceTimer();
_startLoading();
await igcController.getIGCTextData(
textController.text,
[],
);
_acceptNormalizationMatches();
// trigger a re-render of the text field to show IGC matches
textController.setSystemText(
textController.text,
EditTypeEnum.igc,
);
_stopLoading();
igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e));
if (igcController.openAutomaticMatches.isNotEmpty) {
await igcController.acceptNormalizationMatches();
} else {
// trigger a re-render of the text field to show IGC matches
textController.setSystemText(
textController.text,
EditTypeEnum.igc,
);
}
_stopLoading();
if (!igcController.openMatches
.any((match) => match.updatedMatch.isITStart)) {
igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e));
}
}
Future<PangeaMessageContentModel> getMessageContent(String message) async {
@ -279,27 +300,30 @@ class Choreographer extends ChangeNotifier {
);
}
void openIT(PangeaMatchState itMatch) {
if (!itMatch.updatedMatch.isITStart) {
throw Exception("Attempted to open IT with a non-IT start match");
}
void _onUpdateITOpenStatus() {
itController.open.value ? _onOpenIT() : _onCloseIT();
notifyListeners();
}
void _onOpenIT() {
final itMatch = igcController.openMatches.firstWhere(
(match) => match.updatedMatch.isITStart,
orElse: () =>
throw Exception("Attempted to open IT without an ITStart match"),
);
_setChoreoMode(ChoreoModeEnum.it);
final sourceText = currentText;
textController.setSystemText("", EditTypeEnum.it);
itController.openIT(sourceText);
igcController.clear();
itMatch.setStatus(PangeaMatchStatusEnum.accepted);
_record.addRecord(
"",
match: itMatch.updatedMatch,
);
notifyListeners();
_setChoreoMode(ChoreoModeEnum.it);
textController.setSystemText("", EditTypeEnum.it);
}
void _onCloseIT() {
if (itController.open.value) return;
if (currentText.isEmpty && itController.sourceText.value != null) {
textController.setSystemText(
itController.sourceText.value!,
@ -309,16 +333,14 @@ class Choreographer extends ChangeNotifier {
_setChoreoMode(ChoreoModeEnum.igc);
errorService.resetError();
notifyListeners();
}
void onSubmitEdits(String text) {
void _onSubmitSourceTextEdits() {
if (itController.editing.value) return;
textController.setSystemText("", EditTypeEnum.it);
itController.onSubmitEdits(text);
}
void onAcceptContinuance(int index) {
final step = itController.onAcceptContinuance(index);
void _onAcceptContinuance(CompletedITStepModel step) {
textController.setSystemText(
textController.text + step.continuances[step.chosen].text,
EditTypeEnum.it,
@ -335,93 +357,30 @@ class Choreographer extends ChangeNotifier {
errorService.setError(ChoreoError(raw: error));
}
void onAcceptReplacement({
required PangeaMatchState match,
}) {
final updatedMatch = igcController.acceptReplacement(
match,
PangeaMatchStatusEnum.accepted,
);
void _onUpdateMatch(PangeaMatchState match) {
textController.setSystemText(
igcController.currentText!,
EditTypeEnum.igc,
);
if (!updatedMatch.match.isNormalizationError()) {
_record.addRecord(
textController.text,
match: updatedMatch,
);
}
MatrixState.pAnyState.closeOverlay();
inputFocus.requestFocus();
notifyListeners();
}
void onUndoReplacement(PangeaMatchState match) {
igcController.undoReplacement(match);
_record.choreoSteps.removeWhere(
(step) => step.acceptedOrIgnoredMatch == match.updatedMatch,
);
textController.setSystemText(
igcController.currentText!,
EditTypeEnum.igc,
);
MatrixState.pAnyState.closeOverlay();
inputFocus.requestFocus();
notifyListeners();
}
void onIgnoreReplacement({required PangeaMatchState match}) {
final updatedMatch = igcController.ignoreReplacement(match);
if (!updatedMatch.match.isNormalizationError()) {
_record.addRecord(
textController.text,
match: updatedMatch,
);
}
MatrixState.pAnyState.closeOverlay();
inputFocus.requestFocus();
notifyListeners();
}
void _acceptNormalizationMatches() {
final normalizationsMatches = igcController.openNormalizationMatches;
if (normalizationsMatches?.isEmpty ?? true) return;
try {
for (final match in normalizationsMatches!) {
match.selectChoice(
match.updatedMatch.match.choices!.indexWhere(
(c) => c.isBestCorrection,
),
);
final updatedMatch = igcController.acceptReplacement(
match,
PangeaMatchStatusEnum.automatic,
);
textController.setSystemText(
igcController.currentText!,
EditTypeEnum.igc,
);
switch (match.updatedMatch.status) {
case PangeaMatchStatusEnum.accepted:
case PangeaMatchStatusEnum.automatic:
case PangeaMatchStatusEnum.ignored:
_record.addRecord(
currentText,
match: updatedMatch,
textController.text,
match: match.updatedMatch,
);
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"currentText": currentText,
"choreoRecord": _record.toJson(),
},
);
case PangeaMatchStatusEnum.undo:
_record.choreoSteps.removeWhere(
(step) =>
step.acceptedOrIgnoredMatch?.match == match.updatedMatch.match,
);
default:
throw Exception("Unhandled match status: ${match.updatedMatch.status}");
}
inputFocus.requestFocus();
notifyListeners();
}
}

View file

@ -21,12 +21,12 @@ extension ChoregrapherUserSettingsExtension on Choreographer {
return AssistanceStateEnum.error;
}
if (igcController.hasOpenMatches || isRunningIT) {
if (igcController.openMatches.isNotEmpty || isRunningIT) {
return AssistanceStateEnum.fetched;
}
if (isFetching.value) return AssistanceStateEnum.fetching;
if (!igcController.hasIGCTextData &&
if (igcController.currentText == null &&
itController.sourceText.value == null) {
return AssistanceStateEnum.notFetched;
}

View file

@ -1,75 +1,300 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_repo.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_text_data_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_repo.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_request.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class IgcController {
final Function(Object) onError;
bool _isFetching = false;
IGCTextData? _igcTextData;
IgcController(this.onError);
String? get currentText => _igcTextData?.currentText;
bool get hasOpenMatches => _igcTextData?.hasOpenMatches == true;
bool _isFetching = false;
String? _currentText;
PangeaMatchState? get currentlyOpenMatch => _igcTextData?.currentlyOpenMatch;
PangeaMatchState? get firstOpenMatch => _igcTextData?.firstOpenMatch;
List<PangeaMatchState>? get openMatches => _igcTextData?.openMatches;
List<PangeaMatchState>? get recentAutomaticCorrections =>
_igcTextData?.recentAutomaticCorrections;
List<PangeaMatchState>? get openNormalizationMatches =>
_igcTextData?.openNormalizationMatches;
final List<PangeaMatchState> _openMatches = [];
final List<PangeaMatchState> _closedMatches = [];
bool get canShowFirstMatch => _igcTextData?.firstOpenMatch != null;
bool get hasIGCTextData => _igcTextData != null;
StreamController<PangeaMatchState> matchUpdateStream =
StreamController.broadcast();
String? get currentText => _currentText;
List<PangeaMatchState> get openMatches => _openMatches;
List<PangeaMatchState> get recentAutomaticCorrections =>
_closedMatches.reversed
.takeWhile(
(m) => m.updatedMatch.status == PangeaMatchStatusEnum.automatic,
)
.toList();
List<PangeaMatchState> get openAutomaticMatches => _openMatches
.where((match) => match.updatedMatch.match.isNormalizationError())
.toList();
PangeaMatchState? get currentlyOpenMatch {
final RegExp pattern = RegExp(r'span_card_overlay_.+');
final String? matchingKey =
MatrixState.pAnyState.getMatchingOverlayKeys(pattern).firstOrNull;
if (matchingKey == null) return null;
final parts = matchingKey.split('_');
if (parts.length != 5) return null;
final offset = int.tryParse(parts[3]);
final length = int.tryParse(parts[4]);
if (offset == null || length == null) return null;
return _openMatches.firstWhereOrNull(
(match) =>
match.updatedMatch.match.offset == offset &&
match.updatedMatch.match.length == length,
);
}
IGCRequestModel _igcRequest(
String text,
List<PreviousMessage> prevMessages,
) =>
IGCRequestModel(
fullText: text,
userId: MatrixState.pangeaController.userController.userId!,
userL1: MatrixState.pangeaController.languageController.activeL1Code()!,
userL2: MatrixState.pangeaController.languageController.activeL2Code()!,
enableIGC: true,
enableIT: true,
prevMessages: prevMessages,
);
SpanDetailsRequest _spanDetailsRequest(SpanData span) => SpanDetailsRequest(
userL1: MatrixState.pangeaController.languageController.activeL1Code()!,
userL2: MatrixState.pangeaController.languageController.activeL2Code()!,
enableIGC: true,
enableIT: true,
span: span,
);
void dispose() {
matchUpdateStream.close();
}
void clear() {
_isFetching = false;
_igcTextData = null;
_currentText = null;
_openMatches.clear();
_closedMatches.clear();
MatrixState.pAnyState.closeAllOverlays();
}
void clearMatches() => _igcTextData?.clearMatches();
void clearMatches() {
_openMatches.clear();
_closedMatches.clear();
}
void _filterPreviouslyIgnoredMatches() {
for (final match in _openMatches) {
if (IgcRepo.isIgnored(match.updatedMatch)) {
updateOpenMatch(match, PangeaMatchStatusEnum.ignored);
}
}
}
PangeaMatchState? getMatchByOffset(int offset) =>
_igcTextData?.getOpenMatchByOffset(offset);
_openMatches.firstWhereOrNull(
(match) => match.updatedMatch.match.isOffsetInMatchSpan(offset),
);
PangeaMatch acceptReplacement(
void setSpanData(PangeaMatchState matchState, SpanData spanData) {
final openMatch = _openMatches.firstWhereOrNull(
(m) => m.originalMatch == matchState.originalMatch,
);
matchState.setMatch(spanData);
_openMatches.remove(openMatch);
_openMatches.add(matchState);
}
void updateMatch(
PangeaMatchState match,
PangeaMatchStatusEnum status,
) {
if (_igcTextData == null) {
throw "acceptReplacement called with null igcTextData";
PangeaMatchState updated;
switch (status) {
case PangeaMatchStatusEnum.accepted:
case PangeaMatchStatusEnum.automatic:
updated = updateOpenMatch(match, status);
case PangeaMatchStatusEnum.ignored:
IgcRepo.ignore(match.updatedMatch);
updated = updateOpenMatch(match, status);
case PangeaMatchStatusEnum.undo:
updated = updateClosedMatch(match, status);
default:
throw "updateMatch called with unsupported status: $status";
}
final updateMatch = _igcTextData!.acceptMatch(match, status);
return updateMatch;
matchUpdateStream.add(updated);
}
PangeaMatch ignoreReplacement(PangeaMatchState match) {
IgcRepo.ignore(match.updatedMatch);
if (_igcTextData == null) {
throw "should not be in onIgnoreMatch with null igcTextData";
PangeaMatchState updateOpenMatch(
PangeaMatchState matchState,
PangeaMatchStatusEnum status,
) {
final PangeaMatchState openMatch = _openMatches.firstWhere(
(m) => m.originalMatch == matchState.originalMatch,
orElse: () => throw StateError(
'No open match found while updating match.',
),
);
matchState.setStatus(status);
_openMatches.remove(openMatch);
_closedMatches.add(matchState);
switch (status) {
case PangeaMatchStatusEnum.accepted:
case PangeaMatchStatusEnum.automatic:
final choice = matchState.updatedMatch.match.selectedChoice;
if (choice == null) {
throw ArgumentError(
'acceptMatch called with a null selectedChoice.',
);
}
_applyReplacement(
matchState.updatedMatch.match.offset,
matchState.updatedMatch.match.length,
choice.value,
);
case PangeaMatchStatusEnum.ignored:
break;
default:
throw ArgumentError(
'updateOpenMatch called with unsupported status: $status',
);
}
return _igcTextData!.ignoreMatch(match);
return matchState;
}
void undoReplacement(PangeaMatchState match) {
if (_igcTextData == null) {
throw "undoReplacement called with null igcTextData";
PangeaMatchState updateClosedMatch(
PangeaMatchState matchState,
PangeaMatchStatusEnum status,
) {
final closedMatch = _closedMatches.firstWhere(
(m) => m.originalMatch == matchState.originalMatch,
orElse: () => throw StateError(
'No closed match found while updating match.',
),
);
matchState.setStatus(status);
_closedMatches.remove(closedMatch);
final selectedValue = matchState.updatedMatch.match.selectedChoice?.value;
if (selectedValue == null) {
throw StateError(
'Cannot update match without a selectedChoice value.',
);
}
final replacement = matchState.originalMatch.match.fullText.characters
.getRange(
matchState.originalMatch.match.offset,
matchState.originalMatch.match.offset +
matchState.originalMatch.match.length,
)
.toString();
_applyReplacement(
matchState.originalMatch.match.offset,
selectedValue.characters.length,
replacement,
);
return matchState;
}
Future<void> acceptNormalizationMatches() async {
final matches = openAutomaticMatches;
if (matches.isEmpty) return;
final expectedSpans = matches.map((m) => m.originalMatch).toSet();
final completer = Completer<void>();
int completedCount = 0;
late final StreamSubscription<PangeaMatchState> sub;
sub = matchUpdateStream.stream.listen((match) {
if (expectedSpans.remove(match.originalMatch)) {
completedCount++;
if (completedCount >= matches.length) {
completer.complete();
sub.cancel();
}
}
});
try {
for (final match in matches) {
match.selectBestChoice();
updateMatch(match, PangeaMatchStatusEnum.automatic);
}
// If no updates arrive (edge case), auto-timeout after a short delay
Future.delayed(const Duration(seconds: 1), () {
if (!completer.isCompleted) {
completer.complete();
sub.cancel();
}
});
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {"currentText": currentText},
);
if (!completer.isCompleted) completer.complete();
}
return completer.future;
}
/// Applies a text replacement to [_currentText] and adjusts match offsets.
///
/// Called internally when a correction is accepted or undone.
void _applyReplacement(
int offset,
int length,
String replacement,
) {
if (_currentText == null) {
throw StateError('_applyReplacement called with null _currentText');
}
final start = _currentText!.characters.take(offset);
final end = _currentText!.characters.skip(offset + length);
final updatedText = start + replacement.characters + end;
_currentText = updatedText.toString();
for (final list in [_openMatches, _closedMatches]) {
for (final matchState in list) {
final match = matchState.updatedMatch.match;
final updatedMatch = match.copyWith(
fullText: _currentText,
offset: match.offset > offset
? match.offset + replacement.characters.length - length
: match.offset,
);
matchState.setMatch(updatedMatch);
}
}
_igcTextData!.undoMatch(match);
}
Future<void> getIGCTextData(
@ -79,19 +304,10 @@ class IgcController {
if (text.isEmpty) return clear();
if (_isFetching) return;
_isFetching = true;
final IGCRequestModel reqBody = IGCRequestModel(
fullText: text,
userId: MatrixState.pangeaController.userController.userId!,
userL1: MatrixState.pangeaController.languageController.activeL1Code()!,
userL2: MatrixState.pangeaController.languageController.activeL2Code()!,
enableIGC: true,
enableIT: true,
prevMessages: prevMessages,
);
final res = await IgcRepo.get(
MatrixState.pangeaController.userController.accessToken,
reqBody,
_igcRequest(text, prevMessages),
).timeout(
(const Duration(seconds: 10)),
onTimeout: () {
@ -108,7 +324,20 @@ class IgcController {
}
if (!_isFetching) return;
_igcTextData = res.result!;
_currentText = res.result!.originalInput;
for (final match in res.result!.matches) {
final matchState = PangeaMatchState(
match: match.match,
status: PangeaMatchStatusEnum.open,
original: match,
);
if (match.status == PangeaMatchStatusEnum.open) {
_openMatches.add(matchState);
} else {
_closedMatches.add(matchState);
}
}
_filterPreviouslyIgnoredMatches();
_isFetching = false;
}
@ -123,13 +352,7 @@ class IgcController {
final response = await SpanDataRepo.get(
MatrixState.pangeaController.userController.accessToken,
request: SpanDetailsRequest(
userL1: MatrixState.pangeaController.languageController.activeL1Code()!,
userL2: MatrixState.pangeaController.languageController.activeL2Code()!,
enableIGC: true,
enableIT: true,
span: span,
),
request: _spanDetailsRequest(span),
).timeout(
(const Duration(seconds: 10)),
onTimeout: () {
@ -139,17 +362,13 @@ class IgcController {
},
);
if (response.isError) {
throw response.error!;
}
_igcTextData?.setSpanData(match, response.result!);
if (response.isError) throw response.error!;
setSpanData(match, response.result!);
}
Future<void> fetchAllSpanDetails() async {
if (_igcTextData == null) return;
final fetches = <Future>[];
for (final match in _igcTextData!.openMatches) {
for (final match in _openMatches) {
fetches.add(fetchSpanDetails(match: match));
}
await Future.wait(fetches);

View file

@ -7,7 +7,6 @@ import 'package:http/http.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_text_data_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
@ -15,7 +14,7 @@ import '../../common/network/requests.dart';
import '../../common/network/urls.dart';
class _IgcCacheItem {
final Future<IGCTextData> data;
final Future<IGCResponseModel> data;
final DateTime timestamp;
const _IgcCacheItem({
@ -53,7 +52,7 @@ class IgcRepo {
static final Map<String, _IgnoredMatchCacheItem> _ignoredMatchCache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
static Future<Result<IGCTextData>> get(
static Future<Result<IGCResponseModel>> get(
String? accessToken,
IGCRequestModel igcRequest,
) {
@ -70,7 +69,7 @@ class IgcRepo {
return _getResult(igcRequest, future);
}
static Future<IGCTextData> _fetch(
static Future<IGCResponseModel> _fetch(
String? accessToken, {
required IGCRequestModel igcRequest,
}) async {
@ -92,16 +91,12 @@ class IgcRepo {
final Map<String, dynamic> json =
jsonDecode(utf8.decode(res.bodyBytes).toString());
final respModel = IGCResponseModel.fromJson(json);
return IGCTextData(
originalInput: respModel.originalInput,
matches: respModel.matches,
);
return IGCResponseModel.fromJson(json);
}
static Future<Result<IGCTextData>> _getResult(
static Future<Result<IGCResponseModel>> _getResult(
IGCRequestModel request,
Future<IGCTextData> future,
Future<IGCResponseModel> future,
) async {
try {
final res = await future;
@ -117,7 +112,7 @@ class IgcRepo {
}
}
static Future<IGCTextData>? _getCached(
static Future<IGCResponseModel>? _getCached(
IGCRequestModel request,
) {
final cacheKeys = [..._igcCache.keys];
@ -134,7 +129,7 @@ class IgcRepo {
static void _setCached(
IGCRequestModel request,
Future<IGCTextData> response,
Future<IGCResponseModel> response,
) =>
_igcCache[request.hashCode.toString()] = _IgcCacheItem(
data: response,

View file

@ -1,265 +0,0 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_repo.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// A model representing mutable text and match state used by
/// Interactive Grammar Correction (IGC).
///
/// This class tracks the user's original text, the current working text,
/// and the states of grammar matches detected during processing.
/// It provides methods to accept, ignore, or undo corrections, while
/// maintaining consistent text and offset updates across all matches.
class IGCTextData {
/// The user's original text before any corrections or replacements.
final String _originalText;
/// The complete list of detected matches from the initial grammar analysis.
final List<PangeaMatch> _initialMatches;
/// Matches currently awaiting user action (neither accepted nor ignored).
final List<PangeaMatchState> _openMatches = [];
/// Matches that have been resolved, either accepted or ignored.
final List<PangeaMatchState> _closedMatches = [];
/// The current working text after applying accepted corrections.
String _currentText;
/// Creates a new instance of [IGCTextData] from the given [originalInput]
/// and list of grammar [matches].
///
/// Automatically initializes open and closed matches based on their status
/// and filters out previously ignored matches.
IGCTextData({
required String originalInput,
required List<PangeaMatch> matches,
}) : _originalText = originalInput,
_currentText = originalInput,
_initialMatches = matches {
for (final match in matches) {
final matchState = PangeaMatchState(
match: match.match,
status: PangeaMatchStatusEnum.open,
original: match,
);
if (match.status == PangeaMatchStatusEnum.open) {
_openMatches.add(matchState);
} else {
_closedMatches.add(matchState);
}
}
_filterPreviouslyIgnoredMatches();
}
/// Returns a JSON representation of this IGC text data.
Map<String, dynamic> toJson() => {
'original_input': _originalText,
'matches': _initialMatches.map((e) => e.toJson()).toList(),
};
/// The current working text after any accepted replacements.
String get currentText => _currentText;
/// The list of open matches that are still awaiting user action.
List<PangeaMatchState> get openMatches => List.unmodifiable(_openMatches);
/// Whether there are any open matches remaining.
bool get hasOpenMatches => _openMatches.isNotEmpty;
/// The first open match, if one exists.
PangeaMatchState? get firstOpenMatch => _openMatches.firstOrNull;
/// Closed matches that were automatically corrected in recent steps.
///
/// Used to display automatic normalization corrections applied
/// by the IGC system.
List<PangeaMatchState> get recentAutomaticCorrections =>
_closedMatches.reversed
.takeWhile(
(m) => m.updatedMatch.status == PangeaMatchStatusEnum.automatic,
)
.toList();
/// Open matches representing normalization errors that can be auto-corrected.
List<PangeaMatchState> get openNormalizationMatches => _openMatches
.where((match) => match.updatedMatch.match.isNormalizationError())
.toList();
/// Returns the open match that contains the given text [offset], if any.
PangeaMatchState? getOpenMatchByOffset(int offset) =>
_openMatches.firstWhereOrNull(
(match) => match.updatedMatch.match.isOffsetInMatchSpan(offset),
);
/// Returns the match whose span card overlay is currently open, if any.
PangeaMatchState? get currentlyOpenMatch {
final RegExp pattern = RegExp(r'span_card_overlay_.+');
final String? matchingKey =
MatrixState.pAnyState.getMatchingOverlayKeys(pattern).firstOrNull;
if (matchingKey == null) return null;
final parts = matchingKey.split('_');
if (parts.length != 5) return null;
final offset = int.tryParse(parts[3]);
final length = int.tryParse(parts[4]);
if (offset == null || length == null) return null;
return _openMatches.firstWhereOrNull(
(match) =>
match.updatedMatch.match.offset == offset &&
match.updatedMatch.match.length == length,
);
}
/// Clears all match data from this IGC instance.
///
/// Call this when an error occurs that invalidates current match data.
void clearMatches() {
_openMatches.clear();
_closedMatches.clear();
}
/// Filters out any previously ignored matches from the open list.
void _filterPreviouslyIgnoredMatches() {
for (final match in _openMatches) {
if (IgcRepo.isIgnored(match.updatedMatch)) {
ignoreMatch(match);
}
}
}
/// Updates the [matchState] with new [spanData].
///
/// Replaces the existing span information for the given match
/// while maintaining its position in the open list.
void setSpanData(PangeaMatchState matchState, SpanData spanData) {
final openMatch = _openMatches.firstWhereOrNull(
(m) => m.originalMatch == matchState.originalMatch,
);
matchState.setMatch(spanData);
_openMatches.remove(openMatch);
_openMatches.add(matchState);
}
/// Accepts the given [matchState], updates text and state lists,
/// and returns the updated [PangeaMatch].
///
/// Applies the selected replacement text to [_currentText] and
/// updates offsets for all matches accordingly.
PangeaMatch acceptMatch(
PangeaMatchState matchState,
PangeaMatchStatusEnum status,
) {
final openMatch = _openMatches.firstWhere(
(m) => m.originalMatch == matchState.originalMatch,
orElse: () => throw StateError(
'No open match found while accepting match.',
),
);
final choice = matchState.updatedMatch.match.selectedChoice;
if (choice == null) {
throw ArgumentError(
'acceptMatch called with a null selectedChoice.',
);
}
matchState.setStatus(status);
_openMatches.remove(openMatch);
_closedMatches.add(matchState);
_applyReplacement(
matchState.updatedMatch.match.offset,
matchState.updatedMatch.match.length,
choice.value,
);
return matchState.updatedMatch;
}
/// Ignores the given [matchState] and moves it to the closed match list.
///
/// Returns the updated [PangeaMatch] after applying the ignore operation.
PangeaMatch ignoreMatch(PangeaMatchState matchState) {
final openMatch = _openMatches.firstWhere(
(m) => m.originalMatch == matchState.originalMatch,
orElse: () => throw StateError(
'No open match found while ignoring match.',
),
);
matchState.setStatus(PangeaMatchStatusEnum.ignored);
_openMatches.remove(openMatch);
_closedMatches.add(matchState);
return matchState.updatedMatch;
}
/// Undoes a previously accepted match by reverting the replacement
/// and removing it from the closed match list.
void undoMatch(PangeaMatchState matchState) {
final closedMatch = _closedMatches.firstWhere(
(m) => m.originalMatch == matchState.originalMatch,
orElse: () => throw StateError(
'No closed match found while undoing match.',
),
);
_closedMatches.remove(closedMatch);
final selectedValue = matchState.updatedMatch.match.selectedChoice?.value;
if (selectedValue == null) {
throw StateError(
'Cannot undo match without a selectedChoice value.',
);
}
final replacement = matchState.originalMatch.match.fullText.characters
.getRange(
matchState.originalMatch.match.offset,
matchState.originalMatch.match.offset +
matchState.originalMatch.match.length,
)
.toString();
_applyReplacement(
matchState.originalMatch.match.offset,
selectedValue.characters.length,
replacement,
);
}
/// Applies a text replacement to [_currentText] and adjusts match offsets.
///
/// Called internally when a correction is accepted or undone.
void _applyReplacement(
int offset,
int length,
String replacement,
) {
final start = _currentText.characters.take(offset);
final end = _currentText.characters.skip(offset + length);
final updatedText = start + replacement.characters + end;
_currentText = updatedText.toString();
for (final list in [_openMatches, _closedMatches]) {
for (final matchState in list) {
final match = matchState.updatedMatch.match;
final updatedMatch = match.copyWith(
fullText: _currentText,
offset: match.offset > offset
? match.offset + replacement.characters.length - length
: match.offset,
);
matchState.setMatch(updatedMatch);
}
}
}
}

View file

@ -39,6 +39,17 @@ class PangeaMatchState {
setMatch(_match.copyWith(choices: choices));
}
void selectBestChoice() {
if (_match.choices == null) {
throw Exception('No choices available to select best choice from.');
}
selectChoice(
updatedMatch.match.choices!.indexWhere(
(c) => c.isBestCorrection,
),
);
}
Map<String, dynamic> toJson() {
return {
'originalMatch': _original.toJson(),

View file

@ -3,6 +3,7 @@ enum PangeaMatchStatusEnum {
ignored,
accepted,
automatic,
undo,
unknown;
static PangeaMatchStatusEnum fromString(String status) {
@ -16,6 +17,8 @@ enum PangeaMatchStatusEnum {
return PangeaMatchStatusEnum.accepted;
case 'automatic':
return PangeaMatchStatusEnum.automatic;
case 'undo':
return PangeaMatchStatusEnum.undo;
default:
return PangeaMatchStatusEnum.unknown;
}

View file

@ -6,6 +6,7 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/utils/bot_style.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_choice_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
@ -123,9 +124,12 @@ class SpanCardState extends State<SpanCard> {
setState(() {});
}
void _onMatchUpdate(VoidCallback updateFunc) async {
void _updateMatch(PangeaMatchStatusEnum status) {
try {
updateFunc();
widget.choreographer.igcController.updateMatch(
widget.match,
status,
);
widget.showNextMatch();
} catch (e, s) {
ErrorHandler.logError(
@ -141,14 +145,6 @@ class SpanCardState extends State<SpanCard> {
}
}
void _onAcceptReplacement() => _onMatchUpdate(() {
widget.choreographer.onAcceptReplacement(match: widget.match);
});
void _onIgnoreMatch() => _onMatchUpdate(() {
widget.choreographer.onIgnoreReplacement(match: widget.match);
});
@override
Widget build(BuildContext context) {
return SizedBox(
@ -196,8 +192,8 @@ class SpanCardState extends State<SpanCard> {
),
),
_SpanCardButtons(
onAccept: _onAcceptReplacement,
onIgnore: _onIgnoreMatch,
onAccept: () => _updateMatch(PangeaMatchStatusEnum.accepted),
onIgnore: () => _updateMatch(PangeaMatchStatusEnum.ignored),
selectedChoice: _selectedChoice,
),
],

View file

@ -2,19 +2,21 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart';
import '../../../pages/chat/chat.dart';
class StartIGCButton extends StatefulWidget {
final ChatController controller;
final VoidCallback onPressed;
final Choreographer choreographer;
final AssistanceStateEnum initialState;
final Color initialForegroundColor;
final Color initialBackgroundColor;
const StartIGCButton({
super.key,
required this.controller,
required this.onPressed,
required this.choreographer,
required this.initialState,
required this.initialForegroundColor,
required this.initialBackgroundColor,
@ -34,9 +36,6 @@ class _StartIGCButtonState extends State<StartIGCButton>
late Animation<Color?> _backgroundColor;
AssistanceStateEnum? _prevState;
AssistanceStateEnum get state =>
widget.controller.choreographer.assistanceState;
bool _shouldStop = false;
@override
@ -73,12 +72,12 @@ class _StartIGCButtonState extends State<StartIGCButton>
_backgroundColor = AlwaysStoppedAnimation(widget.initialBackgroundColor);
_colorController!.forward(from: 0.0);
widget.controller.choreographer.addListener(_handleStateChange);
widget.choreographer.addListener(_handleStateChange);
}
@override
void dispose() {
widget.controller.choreographer.removeListener(_handleStateChange);
widget.choreographer.removeListener(_handleStateChange);
_spinController?.dispose();
_colorController?.dispose();
super.dispose();
@ -86,7 +85,7 @@ class _StartIGCButtonState extends State<StartIGCButton>
void _handleStateChange() {
final prev = _prevState;
final current = state;
final current = widget.choreographer.assistanceState;
_prevState = current;
if (!mounted || prev == current) return;
@ -123,7 +122,8 @@ class _StartIGCButtonState extends State<StartIGCButton>
return AnimatedBuilder(
animation: Listenable.merge([_colorController!, _spinController!]),
builder: (context, child) {
final enableFeedback = state.allowsFeedback;
final enableFeedback =
widget.choreographer.assistanceState.allowsFeedback;
return Tooltip(
message: enableFeedback ? L10n.of(context).check : "",
child: Material(
@ -134,10 +134,7 @@ class _StartIGCButtonState extends State<StartIGCButton>
child: InkWell(
enableFeedback: enableFeedback,
customBorder: const CircleBorder(),
onTap: enableFeedback
? () =>
widget.controller.onRequestWritingAssistance(manual: true)
: null,
onTap: enableFeedback ? widget.onPressed : null,
onLongPress: enableFeedback
? () => showDialog(
context: context,

View file

@ -61,6 +61,17 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
super.dispose();
}
FullTextTranslationRequestModel _translationRequest(String text) =>
FullTextTranslationRequestModel(
text: text,
tgtLang:
MatrixState.pangeaController.languageController.userL1!.langCode,
userL1:
MatrixState.pangeaController.languageController.userL1!.langCode,
userL2:
MatrixState.pangeaController.languageController.userL2!.langCode,
);
void _openListener() {
if (!mounted) return;
@ -85,45 +96,22 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
ValueNotifier<bool> get _open => widget.choreographer.itController.open;
void _showFeedbackCard(
int index, [
ContinuanceModel continuance, [
Color? borderColor,
String? choiceFeedback,
bool selected = false,
]) {
final currentStep = widget.choreographer.itController.currentITStep.value;
if (currentStep == null) {
ErrorHandler.logError(
m: "currentITStep is null in showCard",
s: StackTrace.current,
data: {
"index": index,
},
);
return;
}
final text = currentStep.continuances[index].text;
final l1Code =
MatrixState.pangeaController.languageController.userL1!.langCode;
final l2Code =
MatrixState.pangeaController.languageController.userL2!.langCode;
final text = continuance.text;
MatrixState.pAnyState.closeOverlay("it_feedback_card");
OverlayUtil.showPositionedCard(
context: context,
cardToShow: choiceFeedback == null
cardToShow: selected
? WordDataCard(
word: text,
langCode: l2Code,
langCode: MatrixState
.pangeaController.languageController.userL2!.langCode,
fullText: _sourceText.value ?? widget.choreographer.currentText,
)
: ITFeedbackCard(
FullTextTranslationRequestModel(
text: text,
tgtLang: l1Code,
userL1: l1Code,
userL2: l2Code,
),
),
: ITFeedbackCard(_translationRequest(text)),
maxHeight: 300,
maxWidth: 300,
borderColor: borderColor,
@ -138,8 +126,7 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
MatrixState.pAnyState.closeOverlay("it_feedback_card");
ContinuanceModel continuance;
try {
continuance =
widget.choreographer.itController.onSelectContinuance(index);
continuance = widget.choreographer.itController.selectContinuance(index);
} catch (e, s) {
ErrorHandler.logError(
e: e,
@ -157,9 +144,9 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
_onCorrectSelection(index);
} else {
_showFeedbackCard(
index,
continuance,
continuance.level == 2 ? ChoreoConstants.yellow : ChoreoConstants.red,
continuance.feedbackText(context),
true,
);
}
}
@ -169,7 +156,7 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
_successTimer = Timer(const Duration(milliseconds: 500), () {
if (!mounted) return;
try {
widget.choreographer.onAcceptContinuance(index);
widget.choreographer.itController.acceptContinuance(index);
} catch (e, s) {
ErrorHandler.logError(
e: e,
@ -223,13 +210,16 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
children: [
_ITBarHeader(
onClose: widget.choreographer.itController.closeIT,
setEditing: widget.choreographer.itController.setEditing,
setEditing:
widget.choreographer.itController.setEditingSourceText,
editing: widget.choreographer.itController.editing,
sourceTextController: _sourceTextController,
sourceText: _sourceText,
onSubmitEdits: (_) => widget.choreographer.onSubmitEdits(
_sourceTextController.text,
),
onSubmitEdits: (_) {
widget.choreographer.itController.submitSourceTextEdits(
_sourceTextController.text,
);
},
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
@ -393,7 +383,7 @@ class _ITBarHeader extends StatelessWidget {
class _ITChoices extends StatelessWidget {
final List<ContinuanceModel> continuances;
final Function(int) onPressed;
final Function(int) onLongPressed;
final Function(ContinuanceModel) onLongPressed;
const _ITChoices({
required this.continuances,
@ -416,7 +406,7 @@ class _ITChoices extends StatelessWidget {
),
],
onPressed: (value, index) => onPressed(index),
onLongPress: (value, index) => onLongPressed(index),
onLongPress: (value, index) => onLongPressed(continuances[index]),
selectedChoiceIndex: null,
langCode: MatrixState.pangeaController.languageController.activeL2Code(),
);

View file

@ -31,6 +31,10 @@ class ITController {
ValueNotifier<bool> get editing => _editing;
ValueNotifier<ITStepModel?> get currentITStep => _currentITStep;
ValueNotifier<String?> get sourceText => _sourceText;
StreamController<CompletedITStepModel> acceptedContinuanceStream =
StreamController.broadcast();
bool _continuing = false;
ITRequestModel _request(String textInput) {
assert(_sourceText.value != null);
@ -55,49 +59,48 @@ class ITController {
);
}
void clear() {
MatrixState.pAnyState.closeOverlay("it_feedback_card");
_open.value = false;
_editing.value = false;
_queue.clear();
_currentITStep.value = null;
_goldRouteTracker = null;
}
void clearSourceText() {
_sourceText.value = null;
}
void dispose() {
acceptedContinuanceStream.close();
_open.dispose();
_currentITStep.dispose();
_editing.dispose();
_currentITStep.dispose();
_sourceText.dispose();
}
void openIT(String text) {
_sourceText.value = text;
_open.value = true;
continueIT();
_continueIT();
}
void closeIT() => clear();
void closeIT() {
MatrixState.pAnyState.closeOverlay("it_feedback_card");
void setEditing(bool value) {
setEditingSourceText(false);
_open.value = false;
_queue.clear();
_currentITStep.value = null;
_goldRouteTracker = null;
}
void setEditingSourceText(bool value) {
_editing.value = value;
}
void onSubmitEdits(String text) {
_editing.value = false;
void submitSourceTextEdits(String text) {
_queue.clear();
_currentITStep.value = null;
_goldRouteTracker = null;
_sourceText.value = text;
continueIT();
setEditingSourceText(false);
_continueIT();
}
ContinuanceModel onSelectContinuance(int index) {
ContinuanceModel selectContinuance(int index) {
if (_currentITStep.value == null) {
throw "onSelectContinuance called when _currentITStep is null";
}
@ -116,7 +119,7 @@ class ITController {
return _currentITStep.value!.continuances[index];
}
CompletedITStepModel onAcceptContinuance(int chosenIndex) {
void acceptContinuance(int chosenIndex) {
if (_currentITStep.value == null) {
throw "onAcceptContinuance called when _currentITStep is null";
}
@ -126,17 +129,16 @@ class ITController {
throw "onAcceptContinuance called with invalid index $chosenIndex";
}
final completedStep = CompletedITStepModel(
_currentITStep.value!.continuances,
chosen: chosenIndex,
acceptedContinuanceStream.add(
CompletedITStepModel(
_currentITStep.value!.continuances,
chosen: chosenIndex,
),
);
continueIT();
return completedStep;
_continueIT();
}
bool _continuing = false;
Future<void> continueIT() async {
Future<void> _continueIT() async {
if (_continuing) return;
_continuing = true;

View file

@ -64,9 +64,9 @@ class PangeaTextController extends TextEditingController {
return existingStyle?.merge(style) ?? style;
}
void setSystemText(String text, EditTypeEnum type) {
void setSystemText(String newText, EditTypeEnum type) {
editType = type;
this.text = text;
text = newText;
}
void _onTextChanged() {
@ -83,7 +83,10 @@ class PangeaTextController extends TextEditingController {
void _onUndo(PangeaMatchState match) {
try {
choreographer.onUndoReplacement(match);
choreographer.igcController.updateMatch(
match,
PangeaMatchStatusEnum.undo,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
@ -111,7 +114,7 @@ class PangeaTextController extends TextEditingController {
return _buildPaywallSpan(style);
}
if (!choreographer.igcController.hasIGCTextData) {
if (choreographer.igcController.currentText == null) {
return TextSpan(text: text, style: style);
}
@ -176,13 +179,9 @@ class PangeaTextController extends TextEditingController {
List<InlineSpan> _buildTokenSpan({
TextStyle? defaultStyle,
}) {
final openMatches = choreographer.igcController.openMatches ?? const [];
final automaticCorrections =
choreographer.igcController.recentAutomaticCorrections ?? const [];
final textSpanMatches = [
...openMatches,
...automaticCorrections,
...choreographer.igcController.openMatches,
...choreographer.igcController.recentAutomaticCorrections,
]..sort(
(a, b) =>
a.updatedMatch.match.offset.compareTo(b.updatedMatch.match.offset),

View file

@ -7,8 +7,13 @@ import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LanguageMismatchPopup extends StatelessWidget {
final String overlayId;
final Future<void> Function() onConfirm;
const LanguageMismatchPopup({super.key, required this.onConfirm});
const LanguageMismatchPopup({
super.key,
required this.overlayId,
required this.onConfirm,
});
@override
Widget build(BuildContext context) {
@ -36,7 +41,7 @@ class LanguageMismatchPopup extends StatelessWidget {
context: context,
future: onConfirm,
);
MatrixState.pAnyState.closeOverlay();
MatrixState.pAnyState.closeOverlay(overlayId);
},
style: TextButton.styleFrom(
backgroundColor: