`QuillEditor.basic` fabrique un `FocusNode` et un `ScrollController` neufs a chaque construction quand on ne lui en passe pas. Or chaque frappe reconstruit ces widgets — compteur de caracteres, pastilles du rail de langues — donc l'editeur perdait le focus des le premier caractere saisi. Les deux conteneurs creent desormais leurs noeuds une fois, dans `initState`, un par langue, et les liberent au `dispose`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
584 lines
20 KiB
Dart
584 lines
20 KiB
Dart
import 'package:manager_app/l10n/app_localizations.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_quill/flutter_quill.dart';
|
|
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
import 'package:manager_app/Components/resource_input_container.dart';
|
|
import 'package:manager_app/Helpers/quill_html_converter.dart';
|
|
import 'package:manager_app/Models/managerContext.dart' show ManagerAppContext;
|
|
import 'package:manager_app/Services/ai_translate_service.dart';
|
|
import 'package:manager_app/constants.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import 'ai_quota_hint.dart';
|
|
import 'flag_decoration.dart';
|
|
import 'message_notification.dart';
|
|
import 'package:manager_app/app_context.dart';
|
|
|
|
class TranslationInputContainer extends StatefulWidget {
|
|
TranslationInputContainer({
|
|
Key? key,
|
|
required this.isTitle,
|
|
required this.values,
|
|
required this.newValues,
|
|
required this.onGetResult,
|
|
required this.maxLines,
|
|
required this.resourceTypes,
|
|
this.isHTML = true,
|
|
}) : super(key: key);
|
|
|
|
bool isTitle;
|
|
List<TranslationDTO> values;
|
|
List<TranslationDTO> newValues;
|
|
Function onGetResult;
|
|
int maxLines;
|
|
List<ResourceType>? resourceTypes;
|
|
|
|
/// Quill quand le champ accepte du HTML, champ simple sinon. C'est la seule
|
|
/// différence entre ce modal et l'ancien `multi_input_modal.dart`, qui en
|
|
/// était le jumeau.
|
|
final bool isHTML;
|
|
|
|
@override
|
|
State<TranslationInputContainer> createState() => _TranslationInputContainerState();
|
|
}
|
|
|
|
class _TranslationInputContainerState extends State<TranslationInputContainer> {
|
|
late Map<String, QuillController> _controllers;
|
|
late Map<String, TextEditingController> _textControllers;
|
|
|
|
/// `QuillEditor.basic` fabrique un `FocusNode` et un `ScrollController` neufs
|
|
/// a chaque construction quand on ne lui en passe pas : le curseur sautait de
|
|
/// l'editeur des la premiere frappe, puisque chaque frappe reconstruit ce
|
|
/// widget (compteur de caracteres, pastilles du rail).
|
|
late Map<String, FocusNode> _focusNodes;
|
|
late Map<String, ScrollController> _editorScrollControllers;
|
|
int _selected = 0;
|
|
bool _isEnforcingLimit = false;
|
|
bool _isTranslating = false;
|
|
int _quotaRefreshToken = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controllers = widget.isHTML ? _buildControllers() : {};
|
|
_textControllers = widget.isHTML ? {} : _buildTextControllers();
|
|
_focusNodes = {
|
|
for (final translation in widget.newValues)
|
|
translation.language!: FocusNode(),
|
|
};
|
|
_editorScrollControllers = {
|
|
for (final translation in widget.newValues)
|
|
translation.language!: ScrollController(),
|
|
};
|
|
}
|
|
|
|
Map<String, TextEditingController> _buildTextControllers() {
|
|
final map = <String, TextEditingController>{};
|
|
for (final translation in widget.newValues) {
|
|
final controller = TextEditingController(text: translation.value ?? '');
|
|
controller.addListener(() {
|
|
translation.value = controller.text;
|
|
});
|
|
map[translation.language!] = controller;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
static const _emptyDelta = [{'insert': '\n'}];
|
|
|
|
List<dynamic> _htmlToDeltaJson(String html) {
|
|
if (html.trim().isEmpty) return _emptyDelta;
|
|
final ops = HtmlToDelta().convert(html).toJson();
|
|
return ops.isEmpty ? _emptyDelta : ops;
|
|
}
|
|
|
|
Map<String, QuillController> _buildControllers() {
|
|
final map = <String, QuillController>{};
|
|
for (final translation in widget.newValues) {
|
|
final html = translation.value ?? '';
|
|
final controller = QuillController(
|
|
document: Document.fromJson(_htmlToDeltaJson(html)),
|
|
selection: const TextSelection.collapsed(offset: 0),
|
|
);
|
|
_setupListener(translation.language!, controller);
|
|
map[translation.language!] = controller;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
void _setupListener(String lang, QuillController controller) {
|
|
controller.document.changes.listen((_) {
|
|
if (!mounted || _isEnforcingLimit) return;
|
|
final limit = widget.isTitle ? kTitleMaxLength : kDescriptionMaxLength;
|
|
final plain = controller.document.toPlainText().trimRight();
|
|
if (plain.length > limit) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && !_isEnforcingLimit) {
|
|
_isEnforcingLimit = true;
|
|
controller.undo();
|
|
_isEnforcingLimit = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
widget.newValues
|
|
.firstWhere((e) => e.language == lang)
|
|
.value = _controllerToHtml(controller);
|
|
// Le compteur de caractères et les pastilles du rail lisent cette valeur.
|
|
setState(() {});
|
|
});
|
|
}
|
|
|
|
String _controllerToHtml(QuillController controller) {
|
|
return quillOpsToHtml(controller.document.toDelta().toJson());
|
|
}
|
|
|
|
Future<void> _translateWithAI() async {
|
|
final appContext = context.read<AppContext>().getContext() as ManagerAppContext;
|
|
if (appContext.instanceDTO?.isAssistant != true) return;
|
|
|
|
final source = widget.newValues.firstWhere(
|
|
(t) => t.value != null && t.value!.trim().isNotEmpty && t.value!.trim() != '<p><br></p>',
|
|
orElse: () => widget.newValues.first,
|
|
);
|
|
|
|
if (source.value == null || source.value!.trim().isEmpty) {
|
|
showNotification(kError, kWhite, AppLocalizations.of(context)!.noSourceTextToTranslate, context, null);
|
|
return;
|
|
}
|
|
|
|
final targetLangs = widget.newValues
|
|
.where((t) => t.language != source.language)
|
|
.map((t) => t.language!)
|
|
.toList();
|
|
|
|
if (targetLangs.isEmpty) return;
|
|
|
|
setState(() => _isTranslating = true);
|
|
|
|
try {
|
|
final translations = await AiTranslateService.translate(
|
|
host: appContext.host!,
|
|
accessToken: appContext.accessToken!,
|
|
instanceId: appContext.instanceId!,
|
|
text: source.value!,
|
|
sourceLang: source.language!,
|
|
targetLangs: targetLangs,
|
|
);
|
|
|
|
setState(() {
|
|
for (final translation in widget.newValues) {
|
|
final lang = translation.language!;
|
|
if (lang == source.language) continue;
|
|
final translated = translations[lang];
|
|
if (translated == null) continue;
|
|
_replaceValue(translation, translated);
|
|
}
|
|
});
|
|
|
|
showNotification(kSuccess, kWhite, AppLocalizations.of(context)!.translationApplied, context, null);
|
|
} catch (e) {
|
|
// Le backend renvoie un message explicite (quota dépassé, assistant désactivé…) :
|
|
// on l'affiche tel quel plutôt qu'une erreur générique.
|
|
final message = e is AiTranslateException
|
|
? e.localized(AppLocalizations.of(context)!)
|
|
: AppLocalizations.of(context)!.aiTranslateError;
|
|
final color = (e is AiTranslateException && e.isQuotaExceeded) ? Colors.orange : kError;
|
|
showNotification(color, kWhite, message, context, null);
|
|
} finally {
|
|
setState(() {
|
|
_isTranslating = false;
|
|
_quotaRefreshToken++;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// La langue affichée sert de source : c'est celle qu'on vient d'écrire.
|
|
void _applyToAllLanguages() {
|
|
final source = widget.newValues[_selected];
|
|
final sourceLang = source.language!;
|
|
final sourceValue = widget.isHTML
|
|
? _controllerToHtml(_controllers[sourceLang]!)
|
|
: (_textControllers[sourceLang]?.text ?? '');
|
|
|
|
setState(() {
|
|
source.value = sourceValue;
|
|
for (final translation in widget.newValues) {
|
|
if (translation.language == sourceLang) continue;
|
|
_replaceValue(translation, sourceValue);
|
|
}
|
|
});
|
|
|
|
showNotification(kSuccess, kWhite, AppLocalizations.of(context)!.textAppliedToAllLanguages, context, null);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final c in _controllers.values) {
|
|
c.dispose();
|
|
}
|
|
for (final c in _textControllers.values) {
|
|
c.dispose();
|
|
}
|
|
for (final node in _focusNodes.values) {
|
|
node.dispose();
|
|
}
|
|
for (final c in _editorScrollControllers.values) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
/// Remplace la valeur d'une langue *et* le contenu de son éditeur. C'est le
|
|
/// seul chemin qui recrée un `QuillController` — la traduction IA et
|
|
/// « appliquer à toutes les langues » passent par ici.
|
|
void _replaceValue(TranslationDTO translation, String value) {
|
|
final lang = translation.language!;
|
|
translation.value = value;
|
|
|
|
if (!widget.isHTML) {
|
|
_textControllers[lang]?.text = value;
|
|
return;
|
|
}
|
|
|
|
_controllers[lang]?.dispose();
|
|
final controller = QuillController(
|
|
document: Document.fromJson(_htmlToDeltaJson(value)),
|
|
selection: const TextSelection.collapsed(offset: 0),
|
|
);
|
|
_setupListener(lang, controller);
|
|
_controllers[lang] = controller;
|
|
}
|
|
|
|
/// ⚠️ Les éditeurs sont tous montés en permanence dans un `IndexedStack` :
|
|
/// la map de `QuillController` est bâtie une fois dans `initState` et libérée
|
|
/// dans `dispose`. Ne jamais reconstruire l'éditeur à la bascule de langue,
|
|
/// et surtout pas de `TabBarView` (construction paresseuse, enfants lâchés
|
|
/// hors écran).
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(width: 132, child: _buildRail()),
|
|
const SizedBox(width: kSpace5),
|
|
Expanded(child: _buildPane(l)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (widget.resourceTypes == null) _buildActionBar(l),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Le rail donne l'état des langues d'un coup d'œil, au lieu de le faire
|
|
/// deviner en faisant défiler un empilement d'éditeurs.
|
|
Widget _buildRail() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (var index = 0; index < widget.newValues.length; index++)
|
|
_buildRailRow(index),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildRailRow(int index) {
|
|
final translation = widget.newValues[index];
|
|
final lang = translation.language!;
|
|
final isSelected = index == _selected;
|
|
final isFilled = _isFilled(translation);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: kSpace2),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
onTap: () => setState(() => _selected = index),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: kSpace3, vertical: kSpace3),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? kSurface : Colors.transparent,
|
|
border: Border.all(
|
|
color: isSelected ? kPrimaryColor : Colors.transparent),
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
FlagDecoration(language: lang),
|
|
const SizedBox(width: kSpace3),
|
|
Expanded(
|
|
child: Text(lang.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight:
|
|
isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
color: kInk)),
|
|
),
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isFilled ? kSuccess : Colors.transparent,
|
|
border: isFilled ? null : Border.all(color: kLine),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _isFilled(TranslationDTO translation) {
|
|
final value = (translation.value ?? '').trim();
|
|
return value.isNotEmpty && value != '<p><br></p>';
|
|
}
|
|
|
|
Widget _buildPane(AppLocalizations l) {
|
|
if (widget.resourceTypes != null) {
|
|
return IndexedStack(
|
|
index: _selected,
|
|
children: [
|
|
for (final translation in widget.newValues)
|
|
SizedBox(
|
|
width: 250,
|
|
child: ResourceInputContainer(
|
|
label: "",
|
|
initialValue: translation.value,
|
|
inResourceTypes: widget.resourceTypes!,
|
|
onChanged: (ResourceDTO resource) {
|
|
translation.value = resource.id;
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final reference = _reference();
|
|
final selectedLang = widget.newValues[_selected].language!;
|
|
final limit = widget.isTitle ? kTitleMaxLength : kDescriptionMaxLength;
|
|
final length = widget.isHTML
|
|
? _controllers[selectedLang]!.document.toPlainText().trimRight().length
|
|
: (_textControllers[selectedLang]?.text.length ?? 0);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// On traduit *depuis* une référence : la langue source reste visible.
|
|
if (reference != null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: kSpace4, vertical: kSpace3),
|
|
decoration: BoxDecoration(
|
|
color: kSurface2,
|
|
border: Border.all(color: kLineSoft),
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Text(reference.language!.toUpperCase(),
|
|
style: const TextStyle(
|
|
fontSize: 10.5,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.5,
|
|
color: kInk3)),
|
|
const SizedBox(width: kSpace3),
|
|
Expanded(
|
|
child: Text(_plainText(reference.value ?? ''),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: kTextSmall),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: kSpace4),
|
|
],
|
|
IndexedStack(
|
|
index: _selected,
|
|
children: [
|
|
for (final translation in widget.newValues)
|
|
_buildEditor(translation.language!),
|
|
],
|
|
),
|
|
const SizedBox(height: kSpace2),
|
|
Text("$length / $limit", style: kTextHint),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// La référence est la première langue renseignée qui n'est pas celle qu'on
|
|
/// est en train d'écrire.
|
|
TranslationDTO? _reference() {
|
|
final selected = widget.newValues[_selected];
|
|
for (final translation in widget.newValues) {
|
|
if (translation.language == selected.language) continue;
|
|
if (_isFilled(translation)) return translation;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _plainText(String html) {
|
|
return html
|
|
.replaceAll(RegExp(r'<[^>]*>'), ' ')
|
|
.replaceAll(RegExp(r'\s+'), ' ')
|
|
.trim();
|
|
}
|
|
|
|
Widget _buildEditor(String lang) {
|
|
if (!widget.isHTML) {
|
|
return TextFormField(
|
|
controller: _textControllers[lang],
|
|
maxLines: widget.maxLines,
|
|
maxLength: widget.isTitle ? kTitleMaxLength : kDescriptionMaxLength,
|
|
style: const TextStyle(fontSize: 13, color: kInk),
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
counterText: '',
|
|
contentPadding: const EdgeInsets.all(kSpace4),
|
|
filled: true,
|
|
fillColor: kSurface,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
borderSide: const BorderSide(color: kLine),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
borderSide: const BorderSide(color: kLine),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final controller = _controllers[lang]!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
QuillSimpleToolbar(
|
|
controller: controller,
|
|
config: _buildToolbarConfig(),
|
|
),
|
|
Container(
|
|
height: widget.isTitle ? 100 : 240,
|
|
decoration: BoxDecoration(
|
|
color: kSurface,
|
|
border: Border.all(color: kLine),
|
|
borderRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(kRadiusInput),
|
|
bottomRight: Radius.circular(kRadiusInput),
|
|
),
|
|
),
|
|
child: QuillEditor.basic(
|
|
controller: controller,
|
|
focusNode: _focusNodes[lang],
|
|
scrollController: _editorScrollControllers[lang],
|
|
config: const QuillEditorConfig(
|
|
scrollable: true,
|
|
expands: false,
|
|
padding: EdgeInsets.all(8),
|
|
autoFocus: false,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Les deux gros boutons ont quitté le flux du contenu : ce sont des actions,
|
|
/// et le quota IA se lit à côté du bouton qu'il conditionne.
|
|
Widget _buildActionBar(AppLocalizations l) {
|
|
final instance = context.watch<AppContext>().getContext()?.instanceDTO;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: kSpace4),
|
|
child: Row(
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: _applyToAllLanguages,
|
|
icon: const Icon(Icons.content_copy, size: 15, color: kInk2),
|
|
label: Text(l.applyToAllLanguages,
|
|
style: const TextStyle(
|
|
fontSize: 13, fontWeight: FontWeight.w600, color: kInk2)),
|
|
),
|
|
if (instance?.isAssistant == true) ...[
|
|
const SizedBox(width: kSpace2),
|
|
if (_isTranslating)
|
|
const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2))
|
|
else
|
|
TextButton.icon(
|
|
onPressed: _translateWithAI,
|
|
icon: const Icon(Icons.auto_awesome,
|
|
size: 15, color: kPrimaryColor),
|
|
label: Text(l.translateWithAi,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: kPrimaryColor)),
|
|
),
|
|
const SizedBox(width: kSpace3),
|
|
Flexible(child: AiQuotaHint(key: ValueKey(_quotaRefreshToken))),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
QuillSimpleToolbarConfig _buildToolbarConfig() {
|
|
return QuillSimpleToolbarConfig(
|
|
showBoldButton: true,
|
|
showItalicButton: true,
|
|
showColorButton: true,
|
|
showBackgroundColorButton: true,
|
|
showListBullets: !widget.isTitle,
|
|
showListNumbers: !widget.isTitle,
|
|
showListCheck: false,
|
|
showClearFormat: true,
|
|
showUnderLineButton: false,
|
|
showStrikeThrough: false,
|
|
showInlineCode: false,
|
|
showSubscript: false,
|
|
showSuperscript: false,
|
|
showSmallButton: false,
|
|
showLineHeightButton: false,
|
|
showHeaderStyle: false,
|
|
showLink: false,
|
|
showSearchButton: false,
|
|
showQuote: false,
|
|
showCodeBlock: false,
|
|
showIndent: false,
|
|
showAlignmentButtons: false,
|
|
showLeftAlignment: false,
|
|
showCenterAlignment: false,
|
|
showRightAlignment: false,
|
|
showJustifyAlignment: false,
|
|
showDirection: false,
|
|
showUndo: false,
|
|
showRedo: false,
|
|
showClipboardCut: false,
|
|
showClipboardCopy: false,
|
|
showClipboardPaste: false,
|
|
);
|
|
}
|
|
}
|