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 values; List newValues; Function onGetResult; int maxLines; List? 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 createState() => _TranslationInputContainerState(); } class _TranslationInputContainerState extends State { late Map _controllers; late Map _textControllers; 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(); } Map _buildTextControllers() { final map = {}; 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 _htmlToDeltaJson(String html) { if (html.trim().isEmpty) return _emptyDelta; final ops = HtmlToDelta().convert(html).toJson(); return ops.isEmpty ? _emptyDelta : ops; } Map _buildControllers() { final map = {}; 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 _translateWithAI() async { final appContext = context.read().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() != '


', 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(); } 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 != '


'; } 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, 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().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, ); } }