refactor: expose text updates from it/igc via streams and respond to those streams in the choreographer
This commit is contained in:
parent
fbd71ef988
commit
0ec17d615e
17 changed files with 1123 additions and 1311 deletions
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -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#
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue