manager-app/lib/Components/translation_input_container.dart

561 lines
19 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;
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<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();
}
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,
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,
);
}
}