diff --git a/lib/desktop/desktop_settings_page.dart b/lib/desktop/desktop_settings_page.dart index 4e4809a2..da672dcd 100644 --- a/lib/desktop/desktop_settings_page.dart +++ b/lib/desktop/desktop_settings_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:provider/provider.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:re_editor/re_editor.dart'; import 'dart:convert'; import 'dart:ui' as ui; @@ -16,6 +17,8 @@ import 'model_fetch_dialog.dart' show showModelFetchDialog; import 'widgets/desktop_select_dropdown.dart'; import '../shared/widgets/ios_switch.dart'; import '../shared/widgets/ios_checkbox.dart'; +import '../shared/widgets/input_height_constraints.dart'; +import '../shared/widgets/plain_text_code_editor.dart'; // Desktop assistants panel dependencies import '../features/assistant/pages/assistant_settings_edit_page.dart' show showAssistantDesktopDialog; // dialog opener only @@ -25,6 +28,7 @@ import '../utils/avatar_cache.dart'; import '../utils/sandbox_path_resolver.dart'; import 'dart:io' show Directory, File, Platform; import '../utils/app_directories.dart'; +import '../utils/re_editor_utils.dart'; import 'add_provider_dialog.dart' show showDesktopAddProviderDialog; import 'model_edit_dialog.dart' show showDesktopCreateModelDialog, showDesktopModelEditDialog; diff --git a/lib/desktop/desktop_translate_page.dart b/lib/desktop/desktop_translate_page.dart index b004463d..c7ac5501 100644 --- a/lib/desktop/desktop_translate_page.dart +++ b/lib/desktop/desktop_translate_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; import '../icons/lucide_adapter.dart' as lucide; import '../l10n/app_localizations.dart'; @@ -11,10 +12,12 @@ import '../core/providers/settings_provider.dart'; import '../core/providers/assistant_provider.dart'; import '../core/services/api/chat_api_service.dart'; import '../shared/widgets/snackbar.dart'; +import '../shared/widgets/plain_text_code_editor.dart'; import '../features/model/widgets/model_select_sheet.dart' show showModelSelector; import '../features/settings/widgets/language_select_sheet.dart' show LanguageOption, supportedLanguages; +import '../utils/re_editor_utils.dart'; class DesktopTranslatePage extends StatefulWidget { const DesktopTranslatePage({super.key}); @@ -24,8 +27,8 @@ class DesktopTranslatePage extends StatefulWidget { } class _DesktopTranslatePageState extends State { - final TextEditingController _source = TextEditingController(); - final TextEditingController _output = TextEditingController(); + final CodeLineEditingController _source = CodeLineEditingController(); + final CodeLineEditingController _output = CodeLineEditingController(); LanguageOption? _targetLang; String? _modelProviderKey; @@ -33,6 +36,7 @@ class _DesktopTranslatePageState extends State { StreamSubscription? _subscription; bool _translating = false; + int _translateRunId = 0; @override void initState() { @@ -133,11 +137,15 @@ class _DesktopTranslatePageState extends State { } Future _startTranslate() async { + if (_translating) return; final l10n = AppLocalizations.of(context)!; final settings = context.read(); final text = _source.text.trim(); if (text.isEmpty) return; + await _subscription?.cancel(); + _subscription = null; + if (!mounted) return; final providerKey = _modelProviderKey; final modelId = _modelId; @@ -159,9 +167,25 @@ class _DesktopTranslatePageState extends State { setState(() { _translating = true; - _output.text = ''; + _output.value = const CodeLineEditingValue.empty(); }); + final runId = ++_translateRunId; + final buffer = StringBuffer(); + Timer? flushTimer; + void flushNow() { + if (!mounted || runId != _translateRunId) return; + _setOutputText(buffer.toString()); + } + + void scheduleFlush() { + if (flushTimer?.isActive ?? false) return; + flushTimer = Timer(const Duration(milliseconds: 80), () { + flushTimer = null; + flushNow(); + }); + } + try { final stream = ChatApiService.sendMessageStream( config: cfg, @@ -174,19 +198,27 @@ class _DesktopTranslatePageState extends State { _subscription = stream.listen( (chunk) { // live update; remove leading whitespace on first chunk to avoid top gap + if (runId != _translateRunId) return; final s = chunk.content; - if (_output.text.isEmpty) { - _output.text = s.replaceFirst(RegExp(r'^\s+'), ''); + if (buffer.isEmpty) { + buffer.write(s.replaceFirst(RegExp(r'^\s+'), '')); } else { - _output.text += s; + buffer.write(s); } + scheduleFlush(); }, onDone: () { - if (!mounted) return; + if (!mounted || runId != _translateRunId) return; + flushTimer?.cancel(); + flushNow(); + _subscription = null; setState(() => _translating = false); }, onError: (e) { - if (!mounted) return; + if (!mounted || runId != _translateRunId) return; + flushTimer?.cancel(); + flushNow(); + _subscription = null; setState(() => _translating = false); showAppSnackBar( context, @@ -197,6 +229,8 @@ class _DesktopTranslatePageState extends State { cancelOnError: true, ); } catch (e) { + _subscription = null; + if (!mounted) return; setState(() => _translating = false); showAppSnackBar( context, @@ -210,9 +244,15 @@ class _DesktopTranslatePageState extends State { try { await _subscription?.cancel(); } catch (_) {} + _subscription = null; + _translateRunId++; if (mounted) setState(() => _translating = false); } + void _setOutputText(String text) { + _output.setTextSafely(text); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -295,26 +335,23 @@ class _DesktopTranslatePageState extends State { overlay: _PaneActionButton( icon: lucide.Lucide.Eraser, label: l10n.translatePageClearAll, - onTap: () { - _source.clear(); - _output.clear(); + onTap: () async { + if (_translating || _subscription != null) { + await _stopTranslate(); + } + _source.value = + const CodeLineEditingValue.empty(); + _output.value = + const CodeLineEditingValue.empty(); }, ), - child: TextField( + child: PlainTextCodeEditor( controller: _source, - keyboardType: TextInputType.multiline, - maxLines: null, - expands: true, - decoration: InputDecoration( - hintText: l10n.translatePageInputHint, - border: InputBorder.none, - isCollapsed: true, - contentPadding: const EdgeInsets.all(14), - ), - style: const TextStyle( - fontSize: 14.5, - height: 1.4, - ), + autofocus: false, + hint: l10n.translatePageInputHint, + padding: const EdgeInsets.all(14), + fontSize: 14.5, + fontHeight: 1.4, ), ), ), @@ -337,22 +374,14 @@ class _DesktopTranslatePageState extends State { ); }, ), - child: TextField( + child: PlainTextCodeEditor( controller: _output, readOnly: true, - keyboardType: TextInputType.multiline, - maxLines: null, - expands: true, - decoration: InputDecoration( - hintText: l10n.translatePageOutputHint, - border: InputBorder.none, - isCollapsed: true, - contentPadding: const EdgeInsets.all(14), - ), - style: const TextStyle( - fontSize: 14.5, - height: 1.4, - ), + autofocus: false, + hint: l10n.translatePageOutputHint, + padding: const EdgeInsets.all(14), + fontSize: 14.5, + fontHeight: 1.4, ), ), ), @@ -473,8 +502,15 @@ class _LanguageDropdownState extends State<_LanguageDropdown> { ); }, ); - Overlay.of(context).insert(_entry!); - setState(() => _open = true); + final overlay = Overlay.maybeOf(context); + if (overlay == null) { + _entry = null; + return; + } + overlay.insert(_entry!); + if (mounted) { + setState(() => _open = true); + } } @override @@ -882,6 +918,7 @@ class _TranslateButtonState extends State<_TranslateButton> { colorFilter: ColorFilter.mode(fg, BlendMode.srcIn), ), const SizedBox(width: 6), + // TODO: Replace hard-coded label with AppLocalizations (i18n). Text( l10n.chatMessageWidgetStopTooltip, style: TextStyle( @@ -898,6 +935,7 @@ class _TranslateButtonState extends State<_TranslateButton> { children: [ Icon(lucide.Lucide.Languages, size: 16, color: fg), const SizedBox(width: 6), + // TODO: Replace hard-coded label with AppLocalizations (i18n). Text( l10n.chatMessageWidgetTranslateTooltip, style: TextStyle( diff --git a/lib/desktop/message_edit_dialog.dart b/lib/desktop/message_edit_dialog.dart index ce3b47d6..c67b9790 100644 --- a/lib/desktop/message_edit_dialog.dart +++ b/lib/desktop/message_edit_dialog.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:re_editor/re_editor.dart'; + import '../core/models/chat_message.dart'; import '../features/chat/models/message_edit_result.dart'; -import '../l10n/app_localizations.dart'; import '../icons/lucide_adapter.dart'; +import '../l10n/app_localizations.dart'; +import '../shared/widgets/plain_text_code_editor.dart'; +import '../utils/re_editor_utils.dart'; Future showMessageEditDesktopDialog( BuildContext context, { @@ -17,6 +21,7 @@ Future showMessageEditDesktopDialog( class _MessageEditDesktopDialog extends StatefulWidget { const _MessageEditDesktopDialog({required this.message}); + final ChatMessage message; @override @@ -25,12 +30,26 @@ class _MessageEditDesktopDialog extends StatefulWidget { } class _MessageEditDesktopDialogState extends State<_MessageEditDesktopDialog> { - late final TextEditingController _controller; + late final CodeLineEditingController _controller; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.message.content); + _controller = CodeLineEditingController(); + _syncControllerText(widget.message.content); + } + + @override + void didUpdateWidget(covariant _MessageEditDesktopDialog oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.content != widget.message.content && + _controller.text == oldWidget.message.content) { + _syncControllerText(widget.message.content); + } + } + + void _syncControllerText(String text) { + _controller.setTextSafely(text); } @override @@ -44,6 +63,7 @@ class _MessageEditDesktopDialogState extends State<_MessageEditDesktopDialog> { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; + return Dialog( elevation: 12, insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), @@ -61,7 +81,6 @@ class _MessageEditDesktopDialogState extends State<_MessageEditDesktopDialog> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Header Padding( padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), child: Row( @@ -124,49 +143,29 @@ class _MessageEditDesktopDialogState extends State<_MessageEditDesktopDialog> { ), ), const SizedBox(height: 4), - // Body Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: TextField( - controller: _controller, - autofocus: true, - keyboardType: TextInputType.multiline, - minLines: 10, - maxLines: null, - decoration: InputDecoration( - hintText: l10n.messageEditPageHint, - filled: true, - fillColor: isDark + child: Container( + decoration: BoxDecoration( + color: isDark ? Colors.white10 : const Color(0xFFF7F7F9), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.18), - width: 0.6, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.18), - width: 0.6, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.35), - width: 0.8, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.18), + width: 0.6, ), ), - style: const TextStyle(fontSize: 15, height: 1.5), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: _controller, + autofocus: true, + hint: l10n.messageEditPageHint, + padding: const EdgeInsets.all(12), + fontSize: 15, + fontHeight: 1.5, + ), ), ), ), diff --git a/lib/desktop/setting/default_model_pane.dart b/lib/desktop/setting/default_model_pane.dart index 4b9664d5..9f3ae6c1 100644 --- a/lib/desktop/setting/default_model_pane.dart +++ b/lib/desktop/setting/default_model_pane.dart @@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart'; import '../../core/providers/settings_provider.dart'; import '../../features/model/widgets/model_select_sheet.dart'; import '../../utils/brand_assets.dart'; +import '../../shared/widgets/input_height_constraints.dart'; import 'package:flutter_svg/flutter_svg.dart'; class DesktopDefaultModelPane extends StatelessWidget { @@ -1009,18 +1010,18 @@ Widget _promptEditor( required TextEditingController controller, required String hintText, }) { - final editorHeight = (MediaQuery.of(context).size.height * 0.45).clamp( - 180.0, - 420.0, + final maxPromptHeight = computeInputMaxHeight( + context: context, + reservedHeight: 220, + softCapFraction: 0.6, + minHeight: 160, ); - return SizedBox( - height: editorHeight.toDouble(), + return ConstrainedBox( + constraints: BoxConstraints(minHeight: 160, maxHeight: maxPromptHeight), child: TextField( controller: controller, maxLines: null, - minLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, + minLines: 8, style: const TextStyle(fontSize: 14), decoration: _deskInputDecoration(context).copyWith(hintText: hintText), ), diff --git a/lib/desktop/setting/instruction_injection_pane.dart b/lib/desktop/setting/instruction_injection_pane.dart index e59a98be..4ddfa3c1 100644 --- a/lib/desktop/setting/instruction_injection_pane.dart +++ b/lib/desktop/setting/instruction_injection_pane.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; import 'package:uuid/uuid.dart'; import '../../icons/lucide_adapter.dart' as lucide; @@ -12,6 +13,8 @@ import '../../core/models/instruction_injection.dart'; import '../../core/providers/instruction_injection_group_provider.dart'; import '../../core/providers/instruction_injection_provider.dart'; import '../../shared/widgets/snackbar.dart'; +import '../../shared/widgets/plain_text_code_editor.dart'; +import '../../utils/re_editor_utils.dart'; class DesktopInstructionInjectionPane extends StatefulWidget { const DesktopInstructionInjectionPane({super.key}); @@ -432,15 +435,52 @@ class _InstructionInjectionEditDialog extends StatefulWidget { class _InstructionInjectionEditDialogState extends State<_InstructionInjectionEditDialog> { late final TextEditingController _titleController; + late final CodeLineEditingController _promptController; late final TextEditingController _groupController; - late final TextEditingController _promptController; @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.initTitle); _groupController = TextEditingController(text: widget.initGroup); - _promptController = TextEditingController(text: widget.initPrompt); + _promptController = CodeLineEditingController.fromText(widget.initPrompt); + } + + @override + void didUpdateWidget(covariant _InstructionInjectionEditDialog oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initTitle != widget.initTitle && + _titleController.text == oldWidget.initTitle) { + _syncTitleText(widget.initTitle); + } + if (oldWidget.initGroup != widget.initGroup && + _groupController.text == oldWidget.initGroup) { + _syncGroupText(widget.initGroup); + } + if (oldWidget.initPrompt != widget.initPrompt && + _promptController.text == oldWidget.initPrompt) { + _syncPromptText(widget.initPrompt); + } + } + + void _syncTitleText(String text) { + _titleController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: TextRange.empty, + ); + } + + void _syncGroupText(String text) { + _groupController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: TextRange.empty, + ); + } + + void _syncPromptText(String text) { + _promptController.setTextSafely(text); } @override @@ -502,12 +542,26 @@ class _InstructionInjectionEditDialogState ), ), const SizedBox(height: 12), - TextField( - controller: _promptController, - maxLines: 8, - decoration: _deskInputDecoration( - context, - ).copyWith(hintText: l10n.instructionInjectionPromptLabel), + Container( + height: 200, + decoration: BoxDecoration( + color: cs.brightness == Brightness.dark + ? Colors.white10 + : const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: _promptController, + autofocus: false, + hint: l10n.instructionInjectionPromptLabel, + padding: const EdgeInsets.all(12), + fontSize: 14, + fontHeight: 1.4, + ), ), ], ), @@ -520,6 +574,7 @@ class _InstructionInjectionEditDialogState filled: true, dense: true, onTap: () { + // TODO: Add inline validation/feedback (e.g., disable Save or show error) when title or prompt is empty/invalid instead of failing silently after submit. Navigator.of(context).pop({ 'title': _titleController.text, 'group': _groupController.text, diff --git a/lib/desktop/setting/providers_pane.dart b/lib/desktop/setting/providers_pane.dart index 0c21620b..2c12165a 100644 --- a/lib/desktop/setting/providers_pane.dart +++ b/lib/desktop/setting/providers_pane.dart @@ -805,8 +805,10 @@ class _DesktopProviderDetailPaneState final TextEditingController _baseUrlCtrl = TextEditingController(); final TextEditingController _locationCtrl = TextEditingController(); final TextEditingController _projectIdCtrl = TextEditingController(); - final TextEditingController _saJsonCtrl = TextEditingController(); + final CodeLineEditingController _saJsonCtrl = CodeLineEditingController(); final TextEditingController _apiPathCtrl = TextEditingController(); + Timer? _saJsonSaveTimer; + String _lastPersistedSaJson = ''; void _syncCtrl(TextEditingController c, String newText) { final v = c.value; @@ -820,17 +822,63 @@ class _DesktopProviderDetailPaneState } } + void _syncCodeCtrl(CodeLineEditingController c, String newText) { + c.setTextSafely(newText); + } + + bool _hasPendingSaJsonChanges() { + return _saJsonSaveTimer != null || _saJsonCtrl.text != _lastPersistedSaJson; + } + void _syncControllersFromConfig(ProviderConfig cfg) { _syncCtrl(_apiKeyCtrl, cfg.apiKey); _syncCtrl(_baseUrlCtrl, cfg.baseUrl); _syncCtrl(_apiPathCtrl, cfg.chatPath ?? '/chat/completions'); _syncCtrl(_locationCtrl, cfg.location ?? ''); _syncCtrl(_projectIdCtrl, cfg.projectId ?? ''); - _syncCtrl(_saJsonCtrl, cfg.serviceAccountJson ?? ''); + final persistedSaJson = cfg.serviceAccountJson ?? ''; + if (!_saJsonCtrl.isComposing && !_hasPendingSaJsonChanges()) { + _syncCodeCtrl(_saJsonCtrl, persistedSaJson); + } + _lastPersistedSaJson = persistedSaJson; + } + + Future _saveSaJsonNow(SettingsProvider sp) async { + final text = _saJsonCtrl.text; + if (text == _lastPersistedSaJson) return; + final old = sp.getProviderConfig( + widget.providerKey, + defaultName: widget.displayName, + ); + await sp.setProviderConfig( + widget.providerKey, + old.copyWith(serviceAccountJson: text), + ); + _lastPersistedSaJson = text; + } + + void _scheduleSaJsonSave(SettingsProvider sp) { + _saJsonSaveTimer?.cancel(); + _saJsonSaveTimer = Timer(const Duration(milliseconds: 400), () { + _saJsonSaveTimer = null; + if (!mounted) return; + _saveSaJsonNow(sp); + }); + } + + void _flushSaJsonSave(SettingsProvider sp) { + _saJsonSaveTimer?.cancel(); + _saJsonSaveTimer = null; + _saveSaJsonNow(sp); } @override void dispose() { + try { + final sp = context.read(); + _flushSaJsonSave(sp); + } catch (_) {} + _saJsonSaveTimer?.cancel(); _filterCtrl.dispose(); _searchFocus.dispose(); _apiKeyCtrl.dispose(); @@ -1457,43 +1505,60 @@ class _DesktopProviderDetailPaneState bold: true, ), const SizedBox(height: 6), - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 120), - child: Focus( - onFocusChange: (has) async { - if (!has) { - final v = _saJsonCtrl.text; - final old = sp.getProviderConfig( - widget.providerKey, - defaultName: widget.displayName, - ); - await sp.setProviderConfig( - widget.providerKey, - old.copyWith(serviceAccountJson: v), - ); - } - }, - child: TextField( - controller: _saJsonCtrl, - maxLines: null, - minLines: 6, - onChanged: (v) async { - if (_saJsonCtrl.value.composing.isValid) return; - final old = sp.getProviderConfig( - widget.providerKey, - defaultName: widget.displayName, - ); - await sp.setProviderConfig( - widget.providerKey, - old.copyWith(serviceAccountJson: v), - ); - }, - style: const TextStyle(fontSize: 14), - decoration: _inputDecoration(context).copyWith( - hintText: '{\n "type": "service_account", ...\n}', + Builder( + builder: (innerCtx) { + final rawMaxSaJsonHeight = computeInputMaxHeight( + context: innerCtx, + reservedHeight: 260, + softCapFraction: 0.6, + minHeight: 120, + ); + final maxSaJsonHeight = rawMaxSaJsonHeight < 120 + ? 120.0 + : rawMaxSaJsonHeight; + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: 120, + maxHeight: maxSaJsonHeight, ), - ), - ), + child: Focus( + onFocusChange: (has) async { + if (!has) { + await _saveSaJsonNow(sp); + } + }, + child: Container( + decoration: BoxDecoration( + color: + Theme.of(context).brightness == Brightness.dark + ? Colors.white10 + : const Color(0xFFF7F7F9), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.12), + width: 0.6, + ), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: _saJsonCtrl, + autofocus: false, + hint: '{\n "type": "service_account", ...\n}', + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + fontSize: 14, + fontHeight: 1.4, + onChanged: (value) async { + if (_saJsonCtrl.isComposing) return; + _scheduleSaJsonSave(sp); + }, + ), + ), + ), + ); + }, ), const SizedBox(height: 8), Align( diff --git a/lib/features/chat/pages/message_edit_page.dart b/lib/features/chat/pages/message_edit_page.dart index b2a10de7..4e58eac0 100644 --- a/lib/features/chat/pages/message_edit_page.dart +++ b/lib/features/chat/pages/message_edit_page.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:re_editor/re_editor.dart'; + import '../../../core/models/chat_message.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../shared/widgets/plain_text_code_editor.dart'; class MessageEditPage extends StatefulWidget { const MessageEditPage({super.key, required this.message}); + final ChatMessage message; @override @@ -11,12 +15,12 @@ class MessageEditPage extends StatefulWidget { } class _MessageEditPageState extends State { - late final TextEditingController _controller; + late final CodeLineEditingController _controller; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.message.content); + _controller = CodeLineEditingController.fromText(widget.message.content); } @override @@ -29,6 +33,7 @@ class _MessageEditPageState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final cs = Theme.of(context).colorScheme; + return Scaffold( appBar: AppBar( title: Text(l10n.messageEditPageTitle), @@ -48,32 +53,21 @@ class _MessageEditPageState extends State { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), - child: TextField( - controller: _controller, - autofocus: true, - keyboardType: TextInputType.multiline, - minLines: 8, - maxLines: null, - decoration: InputDecoration( - hintText: l10n.messageEditPageHint, - filled: true, - fillColor: Theme.of(context).brightness == Brightness.dark + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark ? Colors.white10 : const Color(0xFFF2F3F5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.transparent), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.transparent), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.45), - ), - ), + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: _controller, + autofocus: true, + hint: l10n.messageEditPageHint, + padding: const EdgeInsets.all(16), + fontSize: 15, + fontHeight: 1.5, ), ), ), diff --git a/lib/features/chat/widgets/message_edit_sheet.dart b/lib/features/chat/widgets/message_edit_sheet.dart index 282f28b8..76383fee 100644 --- a/lib/features/chat/widgets/message_edit_sheet.dart +++ b/lib/features/chat/widgets/message_edit_sheet.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:re_editor/re_editor.dart'; import '../../../core/models/chat_message.dart'; import '../models/message_edit_result.dart'; import '../../../l10n/app_localizations.dart'; import '../../../shared/widgets/ios_tactile.dart'; +import '../../../shared/widgets/plain_text_code_editor.dart'; import '../../../core/services/haptics.dart'; +import '../../../utils/re_editor_utils.dart'; Future showMessageEditSheet( BuildContext context, { @@ -30,12 +33,26 @@ class _MessageEditSheet extends StatefulWidget { } class _MessageEditSheetState extends State<_MessageEditSheet> { - late final TextEditingController _controller; + late final CodeLineEditingController _controller; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.message.content); + _controller = CodeLineEditingController(); + _syncControllerText(widget.message.content); + } + + @override + void didUpdateWidget(covariant _MessageEditSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.content != widget.message.content && + _controller.text == oldWidget.message.content) { + _syncControllerText(widget.message.content); + } + } + + void _syncControllerText(String text) { + _controller.setTextSafely(text); } @override @@ -149,35 +166,21 @@ class _MessageEditSheetState extends State<_MessageEditSheet> { ), const SizedBox(height: 12), Expanded( - child: SingleChildScrollView( - controller: sc, - child: TextField( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white10 + : const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( controller: _controller, autofocus: false, - keyboardType: TextInputType.multiline, - minLines: 8, - maxLines: null, - decoration: InputDecoration( - hintText: l10n.messageEditPageHint, - filled: true, - fillColor: Theme.of(context).brightness == Brightness.dark - ? Colors.white10 - : const Color(0xFFF2F3F5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: const BorderSide(color: Colors.transparent), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: const BorderSide(color: Colors.transparent), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.45), - ), - ), - ), + hint: l10n.messageEditPageHint, + padding: const EdgeInsets.all(16), + fontSize: 15, + fontHeight: 1.5, ), ), ), diff --git a/lib/features/home/controllers/home_page_controller.dart b/lib/features/home/controllers/home_page_controller.dart index bffb5888..904f2908 100644 --- a/lib/features/home/controllers/home_page_controller.dart +++ b/lib/features/home/controllers/home_page_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:re_editor/re_editor.dart'; import '../../../core/models/chat_input_data.dart'; import '../../../core/models/chat_message.dart'; import '../../../core/models/conversation.dart'; @@ -64,7 +65,7 @@ class HomePageController extends ChangeNotifier { required GlobalKey scaffoldKey, required GlobalKey inputBarKey, required FocusNode inputFocus, - required TextEditingController inputController, + required CodeLineEditingController inputController, required ChatInputBarController mediaController, required ScrollController scrollController, }) : _context = context, @@ -87,7 +88,7 @@ class HomePageController extends ChangeNotifier { final GlobalKey _scaffoldKey; final GlobalKey _inputBarKey; final FocusNode _inputFocus; - final TextEditingController _inputController; + final CodeLineEditingController _inputController; final ChatInputBarController _mediaController; final ScrollController _scrollController; @@ -174,7 +175,7 @@ class HomePageController extends ChangeNotifier { GlobalKey get scaffoldKey => _scaffoldKey; GlobalKey get inputBarKey => _inputBarKey; FocusNode get inputFocus => _inputFocus; - TextEditingController get inputController => _inputController; + CodeLineEditingController get inputController => _inputController; ChatInputBarController get mediaController => _mediaController; ScrollController get scrollController => _scrollController; Animation get convoFade => _convoFade; @@ -1277,27 +1278,14 @@ class HomePageController extends ChangeNotifier { Future handleQuickPhraseSelection(QuickPhrase? selected) async { if (selected == null) return; - final text = _inputController.text; - final selection = _inputController.selection; - final start = (selection.start >= 0 && selection.start <= text.length) - ? selection.start - : text.length; - final end = - (selection.end >= 0 && - selection.end <= text.length && - selection.end >= start) - ? selection.end - : start; - - final newText = text.replaceRange(start, end, selected.content); - _inputController.value = _inputController.value.copyWith( - text: newText, - selection: TextSelection.collapsed( - offset: start + selected.content.length, - ), - composing: TextRange.empty, - ); - notifyListeners(); + // Use CodeLineEditingController's replaceSelection to insert quick phrase + try { + _inputController.replaceSelection(selected.content); + notifyListeners(); + } catch (_) { + // TODO: Add diagnostics (e.g., debug log + stack trace) when replaceSelection fails to avoid silent drops. + return; + } } // ============================================================================ diff --git a/lib/features/home/pages/home_page.dart b/lib/features/home/pages/home_page.dart index c8bdf8b6..e92752fe 100644 --- a/lib/features/home/pages/home_page.dart +++ b/lib/features/home/pages/home_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../../shared/widgets/interactive_drawer.dart'; @@ -71,7 +72,8 @@ class _HomePageState extends State InteractiveDrawerController(); final ValueNotifier _assistantPickerCloseTick = ValueNotifier(0); final FocusNode _inputFocus = FocusNode(); - final TextEditingController _inputController = TextEditingController(); + final CodeLineEditingController _inputController = + CodeLineEditingController(); final ChatInputBarController _mediaController = ChatInputBarController(); final ScrollController _scrollController = ScrollController(); final BackdropKey _messageListBackdropKey = BackdropKey(); @@ -191,23 +193,13 @@ class _HomePageState extends State if (!mounted) return; final trimmed = text.trim(); if (trimmed.isEmpty) return; - final current = _inputController.text; - final selection = _inputController.selection; - final start = (selection.start >= 0 && selection.start <= current.length) - ? selection.start - : current.length; - final end = - (selection.end >= 0 && - selection.end <= current.length && - selection.end >= start) - ? selection.end - : start; - final next = current.replaceRange(start, end, trimmed); - _inputController.value = _inputController.value.copyWith( - text: next, - selection: TextSelection.collapsed(offset: start + trimmed.length), - composing: TextRange.empty, - ); + // Use CodeLineEditingController's replaceSelection to insert text at cursor + try { + _inputController.replaceSelection(trimmed); + } catch (_) { + // TODO: Add diagnostics (and/or a graceful fallback insert) when replaceSelection fails to avoid silent drops. + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _controller.forceScrollToBottomSoon(animate: false); @@ -830,8 +822,15 @@ class _HomePageState extends State } }, onSend: (text) { + final trimmed = text.text.trim(); + if (trimmed.isEmpty && + text.imagePaths.isEmpty && + text.documents.isEmpty) { + return; + } _controller.sendMessage(text); - _inputController.clear(); + _inputController.value = + const CodeLineEditingValue.empty(); // Clear + reset selection/composing if (PlatformUtils.isMobile) { _controller.dismissKeyboard(); } else { diff --git a/lib/features/home/widgets/chat_input_bar.dart b/lib/features/home/widgets/chat_input_bar.dart index def44aef..ccefbab2 100644 --- a/lib/features/home/widgets/chat_input_bar.dart +++ b/lib/features/home/widgets/chat_input_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:ui' as ui; import 'dart:math' as math; import '../../../theme/design_tokens.dart'; @@ -22,9 +23,29 @@ import '../../../core/services/api/builtin_tools.dart'; import '../../../core/utils/multimodal_input_utils.dart'; import '../../../utils/brand_assets.dart'; import '../../../shared/widgets/ios_tactile.dart'; +import '../../../shared/widgets/plain_text_code_editor.dart'; import '../../../utils/app_directories.dart'; import 'package:super_clipboard/super_clipboard.dart'; import '../../../desktop/desktop_context_menu.dart'; +import 'package:re_editor/re_editor.dart'; + +bool _isDesktopPlatform() { + if (kIsWeb) return false; + try { + return Platform.isMacOS || Platform.isWindows || Platform.isLinux; + } catch (_) { + return false; + } +} + +bool _isIOSPlatform() { + if (kIsWeb) return false; + try { + return Platform.isIOS; + } catch (_) { + return false; + } +} class ChatInputBarController { _ChatInputBarState? _state; @@ -97,7 +118,7 @@ class ChatInputBar extends StatefulWidget { final bool moreOpen; final FocusNode? focusNode; final Widget? modelIcon; - final TextEditingController? controller; + final CodeLineEditingController? controller; final ChatInputBarController? mediaController; final bool loading; final bool reasoningActive; @@ -131,7 +152,8 @@ class ChatInputBar extends StatefulWidget { class _ChatInputBarState extends State with WidgetsBindingObserver { - late TextEditingController _controller; + late CodeLineEditingController _controller; + late final Map> _shortcutOverrideActions; bool _isExpanded = false; // Track expand/collapse state for input field final List _images = []; // local file paths final List _docs = @@ -146,11 +168,7 @@ class _ChatInputBarState extends State final GlobalKey _contextMgmtAnchorKey = GlobalKey( debugLabel: 'context-mgmt-anchor', ); - // Suppress context menu briefly after app resume to avoid flickering - bool _suppressContextMenu = false; - - // Instance method for onChanged to avoid recreating the callback on every build - void _onTextChanged(String _) => setState(() {}); + String _lastText = ''; void _addImages(List paths) { if (paths.isEmpty) return; @@ -185,29 +203,35 @@ class _ChatInputBarState extends State @override void initState() { super.initState(); - _controller = widget.controller ?? TextEditingController(); + _controller = widget.controller ?? CodeLineEditingController(); + _lastText = _controller.text; + _controller.addListener(_onControllerChanged); + _shortcutOverrideActions = { + CodeShortcutNewLineIntent: _ComposingAwareNewLineAction( + isComposing: () => _controller.isComposing, + onInvoke: _handleNewLineIntent, + ), + }; widget.mediaController?._bind(this); WidgetsBinding.instance.addObserver(this); } + // Listener for controller changes (replaces TextField's onChanged) + void _onControllerChanged() { + final text = _controller.text; + if (text == _lastText) return; + _lastText = text; + if (mounted) setState(() {}); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); - // When app resumes from background, suppress context menu briefly to avoid flickering if (state == AppLifecycleState.resumed) { - _suppressContextMenu = true; - // Also unfocus to reset any stuck toolbar state widget.focusNode?.unfocus(); - // Re-enable context menu after a short delay - Future.delayed(const Duration(milliseconds: 300), () { - if (mounted) { - setState(() => _suppressContextMenu = false); - } - }); } else if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused) { // When going to background, hide any open toolbar - _suppressContextMenu = true; widget.focusNode?.unfocus(); } } @@ -215,13 +239,14 @@ class _ChatInputBarState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); - for (final timer in _repeatTimers.values) { + for (final t in _repeatTimers.values) { try { - timer?.cancel(); + t?.cancel(); } catch (_) {} } _repeatTimers.clear(); widget.mediaController?._unbind(this); + _controller.removeListener(_onControllerChanged); if (widget.controller == null) { _controller.dispose(); } @@ -231,6 +256,22 @@ class _ChatInputBarState extends State @override void didUpdateWidget(covariant ChatInputBar oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + final oldController = _controller; + oldController.removeListener(_onControllerChanged); + if (widget.controller != null) { + _controller = widget.controller!; + } else { + final fallback = CodeLineEditingController(); + fallback.value = oldController.value; + _controller = fallback; + } + _lastText = _controller.text; + _controller.addListener(_onControllerChanged); + if (oldWidget.controller == null && oldController != _controller) { + oldController.dispose(); + } + } } String _hint(BuildContext context) { @@ -238,15 +279,125 @@ class _ChatInputBarState extends State return l10n.chatInputBarHint; } - /// Returns the number of lines in the input text (minimum 1). - int get _lineCount { - final text = _controller.text; - if (text.isEmpty) return 1; - return text.split('\n').length; + bool _useDesktopSendShortcuts(BuildContext context) { + if (_isDesktopPlatform()) { + return true; + } + return MediaQuery.sizeOf(context).width >= AppBreakpoints.tablet; } - /// Whether to show the expand/collapse button (when text has 3+ lines). - bool get _showExpandButton => _lineCount >= 3; + SelectionToolbarController? _buildSelectionToolbarController( + BuildContext context, + ) { + if (!_isIOSPlatform()) { + return null; + } + final materialL10n = MaterialLocalizations.of(context); + return MobileSelectionToolbarController( + builder: + ({ + required TextSelectionToolbarAnchors anchors, + required BuildContext context, + required CodeLineEditingController controller, + required VoidCallback onDismiss, + required VoidCallback onRefresh, + }) { + final selection = controller.selection; + final hasSelection = !selection.isCollapsed; + final hasText = controller.text.isNotEmpty; + final buttonItems = [ + if (hasSelection) + ContextMenuButtonItem( + label: materialL10n.cutButtonLabel, + onPressed: () { + controller.cut(); + onDismiss(); + }, + ), + if (hasSelection) + ContextMenuButtonItem( + label: materialL10n.copyButtonLabel, + onPressed: () { + unawaited(controller.copy()); + onDismiss(); + }, + ), + ContextMenuButtonItem( + label: materialL10n.pasteButtonLabel, + onPressed: () { + onDismiss(); + unawaited(_handlePasteFromClipboard()); + }, + ), + if (hasText) + ContextMenuButtonItem( + label: materialL10n.selectAllButtonLabel, + onPressed: () { + controller.selectAll(); + onRefresh(); + }, + ), + ]; + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: anchors, + buttonItems: buttonItems, + ); + }, + ); + } + + ({double height, int lineCount}) _measureInputMetrics({ + required BuildContext context, + required String text, + required double maxWidth, + required double fontSize, + String? fontFamily, + List? fontFamilyFallback, + required double fontHeight, + required double verticalPadding, + required int maxLines, + }) { + if (maxWidth.isInfinite || maxWidth <= 0) { + final fallbackLineHeight = fontSize * fontHeight; + return (height: fallbackLineHeight + verticalPadding, lineCount: 1); + } + + const int wrapMeasureLimit = 4000; + final limitedText = text.length > wrapMeasureLimit + ? text.substring(0, wrapMeasureLimit) + : text; + final effectiveText = limitedText.isEmpty ? ' ' : limitedText; + + final painter = TextPainter( + // IMPORTANT: CodeEditor builds its own TextStyle (doesn't merge DefaultTextStyle), + // so our measurement must match that exactly; otherwise wrap thresholds will drift. + text: TextSpan( + text: effectiveText, + style: TextStyle( + fontSize: fontSize, + height: fontHeight, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + ), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + locale: Localizations.maybeLocaleOf(context), + maxLines: maxLines, + ); + try { + painter.layout(maxWidth: maxWidth); + + final metrics = painter.computeLineMetrics(); + final lineCount = metrics.isEmpty ? 1 : metrics.length; + final lineHeight = fontSize * fontHeight; + final textHeight = math.max(painter.height, lineHeight); + return (height: textHeight + verticalPadding, lineCount: lineCount); + } finally { + // TODO: Verify TextPainter.dispose() availability on our minimum Flutter SDK; remove if unsupported. + painter.dispose(); + } + } void _handleSend() { final text = _controller.text.trim(); @@ -258,234 +409,89 @@ class _ChatInputBarState extends State documents: List.of(_docs), ), ); - _controller.clear(); + _controller.value = + const CodeLineEditingValue.empty(); // Clear + reset selection/composing _images.clear(); _docs.clear(); setState(() {}); // Keep focus on desktop so user can continue typing - try { - if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { - widget.focusNode?.requestFocus(); - } - } catch (_) {} + if (_isDesktopPlatform()) { + widget.focusNode?.requestFocus(); + } } void _insertNewlineAtCursor() { - final value = _controller.value; - final selection = value.selection; - final text = value.text; - if (!selection.isValid) { - _controller.text = '$text\n'; - _controller.selection = TextSelection.collapsed( - offset: _controller.text.length, - ); - } else { - final start = selection.start; - final end = selection.end; - final newText = text.replaceRange(start, end, '\n'); - _controller.value = value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: start + 1), - composing: TextRange.empty, - ); - } - setState(() {}); - _ensureCaretVisible(); + // CodeLineEditingController has a built-in method to insert newlines + _controller.applyNewLine(); + _controller.makeCursorVisible(); } - // Keep the caret visible after programmatic edits (e.g., Shift+Enter insert) - void _ensureCaretVisible() { - try { - final selection = _controller.selection; - if (!selection.isValid) return; - final focusNode = widget.focusNode ?? Focus.maybeOf(context); - final focusContext = focusNode?.context; - if (focusContext == null) return; - final editable = focusContext - .findAncestorStateOfType(); - if (editable == null) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - try { - editable.bringIntoView(selection.extent); - } catch (_) {} - }); - } catch (_) {} - } + Object? _handleNewLineIntent(CodeShortcutNewLineIntent intent) { + final keys = HardwareKeyboard.instance.logicalKeysPressed; + final shift = + keys.contains(LogicalKeyboardKey.shiftLeft) || + keys.contains(LogicalKeyboardKey.shiftRight); + final ctrl = + keys.contains(LogicalKeyboardKey.controlLeft) || + keys.contains(LogicalKeyboardKey.controlRight); + final meta = + keys.contains(LogicalKeyboardKey.metaLeft) || + keys.contains(LogicalKeyboardKey.metaRight); + final ctrlOrMeta = ctrl || meta; - // Instance method for contextMenuBuilder to avoid flickering caused by recreating - // the callback on every build. See: https://github.com/flutter/flutter/issues/150551 - Widget _buildContextMenu(BuildContext context, EditableTextState state) { - // Suppress context menu during app lifecycle transitions to avoid flickering - if (_suppressContextMenu) { - return const SizedBox.shrink(); - } - if (Platform.isIOS) { - final items = []; - try { - final appL10n = AppLocalizations.of(context)!; - final materialL10n = MaterialLocalizations.of(context); - final value = _controller.value; - final selection = value.selection; - final hasSelection = selection.isValid && !selection.isCollapsed; - final hasText = value.text.isNotEmpty; - - // Cut - if (hasSelection) { - items.add( - ContextMenuButtonItem( - onPressed: () async { - try { - final start = selection.start; - final end = selection.end; - final text = value.text.substring(start, end); - await Clipboard.setData(ClipboardData(text: text)); - final newText = value.text.replaceRange(start, end, ''); - _controller.value = value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: start), - ); - } catch (_) {} - state.hideToolbar(); - }, - label: materialL10n.cutButtonLabel, - ), - ); + if (_useDesktopSendShortcuts(context)) { + final sendShortcut = context.read().desktopSendShortcut; + if (sendShortcut == DesktopSendShortcut.ctrlEnter) { + if (ctrlOrMeta) { + _handleSend(); + } else { + _insertNewlineAtCursor(); } - - // Copy - if (hasSelection) { - items.add( - ContextMenuButtonItem( - onPressed: () async { - try { - final start = selection.start; - final end = selection.end; - final text = value.text.substring(start, end); - await Clipboard.setData(ClipboardData(text: text)); - } catch (_) {} - state.hideToolbar(); - }, - label: materialL10n.copyButtonLabel, - ), - ); + } else { + if (shift || ctrlOrMeta) { + _insertNewlineAtCursor(); + } else { + _handleSend(); } + } + return null; + } - // Paste (text or image via _handlePasteFromClipboard) - items.add( - ContextMenuButtonItem( - onPressed: () { - _handlePasteFromClipboard(); - state.hideToolbar(); - }, - label: materialL10n.pasteButtonLabel, - ), - ); - - // Insert newline - items.add( - ContextMenuButtonItem( - onPressed: () { - _insertNewlineAtCursor(); - state.hideToolbar(); - }, - label: appL10n.chatInputBarInsertNewline, - ), - ); - - // Select all - if (hasText) { - items.add( - ContextMenuButtonItem( - onPressed: () { - try { - _controller.selection = TextSelection( - baseOffset: 0, - extentOffset: value.text.length, - ); - } catch (_) {} - state.hideToolbar(); - }, - label: materialL10n.selectAllButtonLabel, - ), - ); - } - } catch (_) {} - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: state.contextMenuAnchors, - buttonItems: items, - ); + final enterToSendOnMobile = context + .read() + .enterToSendOnMobile; + if (shift || !enterToSendOnMobile) { + _insertNewlineAtCursor(); + } else { + _handleSend(); } + return null; + } - // Other platforms: keep default behavior. - final items = [...state.contextMenuButtonItems]; - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: state.contextMenuAnchors, - buttonItems: items, - ); + // Keep the caret visible after programmatic edits (e.g., Shift+Enter insert) + void _ensureCaretVisible() { + try { + // CodeLineEditingController has built-in method to ensure cursor visibility + _controller.makeCursorVisible(); + } catch (_) {} } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { // Enhance hardware keyboard behavior - final w = MediaQuery.sizeOf(node.context!).width; + final nodeContext = node.context; + if (nodeContext == null) return KeyEventResult.ignored; + final w = MediaQuery.sizeOf(nodeContext).width; final isTabletOrDesktop = w >= AppBreakpoints.tablet; - final isIosTablet = Platform.isIOS && isTabletOrDesktop; + final isIosTablet = _isIOSPlatform() && isTabletOrDesktop; - final isDown = event is KeyDownEvent; final key = event.logicalKey; - final isEnter = - key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.numpadEnter; final isArrow = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; final isPasteV = key == LogicalKeyboardKey.keyV; - // Enter handling on tablet/desktop: configurable shortcut - if (isEnter && isTabletOrDesktop) { - if (!isDown) return KeyEventResult.handled; // ignore key up - // Respect IME composition (e.g., Chinese Pinyin). If composing, let IME handle Enter. - final composing = _controller.value.composing; - final composingActive = composing.isValid && !composing.isCollapsed; - if (composingActive) return KeyEventResult.ignored; - final keys = HardwareKeyboard.instance.logicalKeysPressed; - final shift = - keys.contains(LogicalKeyboardKey.shiftLeft) || - keys.contains(LogicalKeyboardKey.shiftRight); - final ctrl = - keys.contains(LogicalKeyboardKey.controlLeft) || - keys.contains(LogicalKeyboardKey.controlRight); - final meta = - keys.contains(LogicalKeyboardKey.metaLeft) || - keys.contains(LogicalKeyboardKey.metaRight); - final ctrlOrMeta = ctrl || meta; - // Get send shortcut setting - final sendShortcut = Provider.of( - node.context!, - listen: false, - ).desktopSendShortcut; - if (sendShortcut == DesktopSendShortcut.ctrlEnter) { - // Ctrl/Cmd+Enter to send, Enter to newline - if (ctrlOrMeta) { - _handleSend(); - } else if (!shift) { - _insertNewlineAtCursor(); - } else { - // Shift+Enter also newline - _insertNewlineAtCursor(); - } - } else { - // Enter to send, Shift+Enter or Ctrl/Cmd+Enter to newline (default) - if (shift || ctrlOrMeta) { - _insertNewlineAtCursor(); - } else { - _handleSend(); - } - } - return KeyEventResult.handled; - } - // Paste handling for images on iOS/macOS (tablet/desktop) + final isDown = event is KeyDownEvent; if (isDown && isPasteV) { final keys = HardwareKeyboard.instance.logicalKeysPressed; final meta = @@ -523,7 +529,7 @@ class _ChatInputBarState extends State } } - if (event is KeyDownEvent) { + if (isDown) { // Initial move moveOnce(); // Start repeat timer if not already @@ -537,9 +543,7 @@ class _ChatInputBarState extends State _repeatTimers[key] = starter; } return KeyEventResult.handled; - } - - if (event is KeyUpEvent) { + } else { // Key up -> cancel repeat final t = _repeatTimers.remove(key); try { @@ -547,8 +551,6 @@ class _ChatInputBarState extends State } catch (_) {} return KeyEventResult.handled; } - - return KeyEventResult.handled; } Future _handlePasteFromClipboard() async { @@ -597,12 +599,11 @@ class _ChatInputBarState extends State } final ts = DateTime.now().millisecondsSinceEpoch; final ext = format.toLowerCase(); - final fileExt = ext == 'jpeg' ? 'jpg' : ext; - String name = 'paste_$ts.$fileExt'; + String name = 'paste_$ts.${ext == 'jpeg' ? 'jpg' : ext}'; String destPath = p.join(dir.path, name); if (await File(destPath).exists()) { name = - 'paste_${ts}_${DateTime.now().microsecondsSinceEpoch}.$fileExt'; + 'paste_${ts}_${DateTime.now().microsecondsSinceEpoch}.${ext == 'jpeg' ? 'jpg' : ext}'; destPath = p.join(dir.path, name); } await File(destPath).writeAsBytes(bytes, flush: true); @@ -657,6 +658,7 @@ class _ChatInputBarState extends State if (bytes != null && bytes.isNotEmpty && fmt != null) { final savedPath = await saveImageBytes(fmt, bytes); + if (!mounted) return; if (savedPath != null) { _addImages([savedPath]); return; @@ -667,26 +669,10 @@ class _ChatInputBarState extends State if (reader.canProvide(Formats.plainText)) { try { final String? text = await reader.readValue(Formats.plainText); + if (!mounted) return; if (text != null && text.isNotEmpty) { - final value = _controller.value; - final sel = value.selection; - if (!sel.isValid) { - _controller.text = value.text + text; - _controller.selection = TextSelection.collapsed( - offset: _controller.text.length, - ); - } else { - final start = sel.start; - final end = sel.end; - final newText = value.text.replaceRange(start, end, text); - _controller.value = value.copyWith( - text: newText, - selection: TextSelection.collapsed( - offset: start + text.length, - ), - composing: TextRange.empty, - ); - } + // Use CodeLineEditingController's replaceSelection for pasting + _controller.replaceSelection(text); setState(() {}); return; } @@ -699,6 +685,7 @@ class _ChatInputBarState extends State final imageTempPaths = await ClipboardImages.getImagePaths(); if (imageTempPaths.isNotEmpty) { final persisted = await _persistClipboardImages(imageTempPaths); + if (!mounted) return; if (persisted.isNotEmpty) { _addImages(persisted); } @@ -708,10 +695,11 @@ class _ChatInputBarState extends State // 3) Try files via platform channel on desktop (Finder/Explorer copies) bool handledFiles = false; try { - if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + if (_isDesktopPlatform()) { final filePaths = await ClipboardImages.getFilePaths(); if (filePaths.isNotEmpty) { final saved = await _copyFilesToUpload(filePaths); + if (!mounted) return; if (saved.images.isNotEmpty) _addImages(saved.images); if (saved.docs.isNotEmpty) _addFiles(saved.docs); handledFiles = saved.images.isNotEmpty || saved.docs.isNotEmpty; @@ -723,25 +711,11 @@ class _ChatInputBarState extends State // 4) Last resort: paste text via Flutter Clipboard API try { final data = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted) return; final text = data?.text ?? ''; if (text.isEmpty) return; - final value = _controller.value; - final sel = value.selection; - if (!sel.isValid) { - _controller.text = value.text + text; - _controller.selection = TextSelection.collapsed( - offset: _controller.text.length, - ); - } else { - final start = sel.start; - final end = sel.end; - final newText = value.text.replaceRange(start, end, text); - _controller.value = value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: start + text.length), - composing: TextRange.empty, - ); - } + // Use CodeLineEditingController's replaceSelection for pasting + _controller.replaceSelection(text); setState(() {}); } catch (_) {} } @@ -754,10 +728,8 @@ class _ChatInputBarState extends State final docs = []; try { final dir = await AppDirectories.getUploadDirectory(); + if (!mounted) return (images: images, docs: docs); for (final raw in srcPaths) { - if (!mounted) { - return (images: images, docs: docs); - } final src = raw.startsWith('file://') ? raw.substring(7) : raw; final savedPath = await FileImportHelper.copyXFile( XFile(src), @@ -1242,6 +1214,16 @@ class _ChatInputBarState extends State final mediaMime = inferMediaMimeFromSource(name); if (mediaMime.isNotEmpty) return mediaMime; final lower = name.toLowerCase(); + // Video + if (lower.endsWith('.mp4')) return 'video/mp4'; + if (lower.endsWith('.mov')) return 'video/quicktime'; + if (lower.endsWith('.mpeg') || lower.endsWith('.mpg')) return 'video/mpeg'; + if (lower.endsWith('.avi')) return 'video/x-msvideo'; + if (lower.endsWith('.mkv')) return 'video/x-matroska'; + if (lower.endsWith('.flv')) return 'video/x-flv'; + if (lower.endsWith('.wmv')) return 'video/x-ms-wmv'; + if (lower.endsWith('.webm')) return 'video/webm'; + if (lower.endsWith('.3gp') || lower.endsWith('.3gpp')) return 'video/3gpp'; // Documents / text if (lower.endsWith('.pdf')) return 'application/pdf'; if (lower.endsWith('.docx')) { @@ -1321,46 +1303,32 @@ class _ChatInputBarState extends State } void _moveCaret(int dir, {bool extend = false, bool byWord = false}) { - final text = _controller.text; - if (text.isEmpty) return; - TextSelection sel = _controller.selection; - if (!sel.isValid) { - final off = dir < 0 ? text.length : 0; - _controller.selection = TextSelection.collapsed(offset: off); - return; - } - - int nextOffset(int from, int direction) { - if (!byWord) return (from + direction).clamp(0, text.length); - // Move by simple word boundary: skip whitespace; then skip non-whitespace - int i = from; - if (direction < 0) { - // Move left - while (i > 0 && text[i - 1].trim().isEmpty) { - i--; - } - while (i > 0 && text[i - 1].trim().isNotEmpty) { - i--; + // Use CodeLineEditingController's built-in methods for cursor movement + if (_controller.text.isEmpty) return; + + if (byWord) { + if (extend) { + // Extend selection to word boundary + if (dir < 0) { + _controller.extendSelectionToWordBoundaryBackward(); + } else { + _controller.extendSelectionToWordBoundaryForward(); } } else { - // Move right - while (i < text.length && text[i].trim().isEmpty) { - i++; - } - while (i < text.length && text[i].trim().isNotEmpty) { - i++; + // Move cursor to word boundary + if (dir < 0) { + _controller.moveCursorToWordBoundaryBackward(); + } else { + _controller.moveCursorToWordBoundaryForward(); } } - return i.clamp(0, text.length); - } - - if (extend) { - final newExtent = nextOffset(sel.extentOffset, dir); - _controller.selection = sel.copyWith(extentOffset: newExtent); } else { - final base = dir < 0 ? sel.start : sel.end; - final collapsed = nextOffset(base, dir); - _controller.selection = TextSelection.collapsed(offset: collapsed); + final direction = dir < 0 ? AxisDirection.left : AxisDirection.right; + if (extend) { + _controller.extendSelection(direction); + } else { + _controller.moveCursor(direction); + } } setState(() {}); } @@ -1381,10 +1349,15 @@ class _ChatInputBarState extends State (hasImages ? 64 + AppSpacing.xs : 0); const double baseChromeHeight = 120; // padding + action row + chrome buffer double maxInputHeight = double.infinity; - if (isMobileLayout) { + // Double insurance (same spirit as old TextField behavior): + // - Outer cap: keep the whole input bar above keyboard + attachments even when expanded (incl. desktop narrow windows). + // - Inner cap: editor itself will still have its own "max lines" viewport and scroll internally. + final bool shouldCapInputHeight = isMobileLayout || _isExpanded; + if (shouldCapInputHeight) { final double available = visibleHeight - attachmentsHeight - baseChromeHeight; - final double softCap = visibleHeight * 0.45; + // Allow a bit more room on larger layouts, but still keep context visible. + final double softCap = visibleHeight * (isMobileLayout ? 0.45 : 0.60); if (available > 0) { maxInputHeight = math.min(softCap, available); maxInputHeight = math.min(available, math.max(80.0, maxInputHeight)); @@ -1392,9 +1365,8 @@ class _ChatInputBarState extends State maxInputHeight = math.max(80.0, softCap); } } - // Cap text field height on mobile so expanded input stays above the keyboard. final BoxConstraints textFieldConstraints = - (isMobileLayout && maxInputHeight.isFinite && maxInputHeight > 0) + (maxInputHeight.isFinite && maxInputHeight > 0) ? BoxConstraints(maxHeight: maxInputHeight) : const BoxConstraints(); @@ -1548,166 +1520,202 @@ class _ChatInputBarState extends State child: Column( children: [ // Input field with expand/collapse button - Stack( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.xxs, - AppSpacing.md, - AppSpacing.xs, - ), - child: ConstrainedBox( - constraints: textFieldConstraints, - child: Focus( - onKeyEvent: _handleKeyEvent, - child: Builder( - builder: (ctx) { - // Desktop: show a right-click context menu with paste/cut/copy/select all - // Future _showDesktopContextMenu(Offset globalPos) async { - // bool isDesktop = false; - // try { isDesktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; } catch (_) {} - // if (!isDesktop) return; - // // Ensure input has focus so operations apply correctly - // try { widget.focusNode?.requestFocus(); } catch (_) {} - // - // final sel = _controller.selection; - // final hasSelection = sel.isValid && !sel.isCollapsed; - // final hasText = _controller.text.isNotEmpty; - // - // final l10n = MaterialLocalizations.of(ctx); - // await showDesktopContextMenuAt( - // ctx, - // globalPosition: globalPos, - // items: [ - // DesktopContextMenuItem( - // icon: Lucide.Clipboard, - // label: l10n.pasteButtonLabel, - // onTap: () async { - // await _handlePasteFromClipboard(); - // }, - // ), - // DesktopContextMenuItem( - // icon: Lucide.Cut, - // label: l10n.cutButtonLabel, - // onTap: () async { - // final s = _controller.selection; - // if (s.isValid && !s.isCollapsed) { - // final text = _controller.text.substring(s.start, s.end); - // try { await Clipboard.setData(ClipboardData(text: text)); } catch (_) {} - // final newText = _controller.text.replaceRange(s.start, s.end, ''); - // _controller.value = TextEditingValue( - // text: newText, - // selection: TextSelection.collapsed(offset: s.start), - // ); - // setState(() {}); - // } - // }, - // ), - // DesktopContextMenuItem( - // icon: Lucide.Copy, - // label: l10n.copyButtonLabel, - // onTap: () async { - // final s2 = _controller.selection; - // if (s2.isValid && !s2.isCollapsed) { - // final text = _controller.text.substring(s2.start, s2.end); - // try { await Clipboard.setData(ClipboardData(text: text)); } catch (_) {} - // } - // }, - // ), - // // DesktopContextMenuItem( - // // // icon: Lucide.TextSelect, - // // label: l10n.selectAllButtonLabel, - // // onTap: () { - // // if (hasText) { - // // _controller.selection = TextSelection(baseOffset: 0, extentOffset: _controller.text.length); - // // setState(() {}); - // // } - // // }, - // // ), - // ], - // ); - // } - - final enterToSend = context - .watch() - .enterToSendOnMobile; - return GestureDetector( - behavior: HitTestBehavior.deferToChild, - // onSecondaryTapDown: (details) { - // // _showDesktopContextMenu(details.globalPosition); - // }, - child: TextField( + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.xxs, + AppSpacing.md, + AppSpacing.xs, + ), + child: ConstrainedBox( + constraints: textFieldConstraints, + child: LayoutBuilder( + builder: (ctx, constraints) { + // Desktop: show a right-click context menu with paste/cut/copy/select all + // Future _showDesktopContextMenu(Offset globalPos) async { + // bool isDesktop = false; + // try { isDesktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; } catch (_) {} + // if (!isDesktop) return; + // // Ensure input has focus so operations apply correctly + // try { widget.focusNode?.requestFocus(); } catch (_) {} + // + // final sel = _controller.selection; + // final hasSelection = sel.isValid && !sel.isCollapsed; + // final hasText = _controller.text.isNotEmpty; + // + // final l10n = MaterialLocalizations.of(ctx); + // await showDesktopContextMenuAt( + // ctx, + // globalPosition: globalPos, + // items: [ + // DesktopContextMenuItem( + // icon: Lucide.Clipboard, + // label: l10n.pasteButtonLabel, + // onTap: () async { + // await _handlePasteFromClipboard(); + // }, + // ), + // DesktopContextMenuItem( + // icon: Lucide.Cut, + // label: l10n.cutButtonLabel, + // onTap: () async { + // final s = _controller.selection; + // if (s.isValid && !s.isCollapsed) { + // final text = _controller.text.substring(s.start, s.end); + // try { await Clipboard.setData(ClipboardData(text: text)); } catch (_) {} + // final newText = _controller.text.replaceRange(s.start, s.end, ''); + // _controller.value = TextEditingValue( + // text: newText, + // selection: TextSelection.collapsed(offset: s.start), + // ); + // setState(() {}); + // } + // }, + // ), + // DesktopContextMenuItem( + // icon: Lucide.Copy, + // label: l10n.copyButtonLabel, + // onTap: () async { + // final s2 = _controller.selection; + // if (s2.isValid && !s2.isCollapsed) { + // final text = _controller.text.substring(s2.start, s2.end); + // try { await Clipboard.setData(ClipboardData(text: text)); } catch (_) {} + // } + // }, + // ), + // // DesktopContextMenuItem( + // // // icon: Lucide.TextSelect, + // // label: l10n.selectAllButtonLabel, + // // onTap: () { + // // if (hasText) { + // // _controller.selection = TextSelection(baseOffset: 0, extentOffset: _controller.text.length); + // // setState(() {}); + // // } + // // }, + // // ), + // ], + // ); + // } + + // enterToSend setting is checked but CodeEditor handles Enter differently. + // Keep watching to rebuild when the setting changes. + // ignore: unused_local_variable + final enterToSendOnMobile = context + .watch() + .enterToSendOnMobile; + final fontSize = _isDesktopPlatform() + ? 14.0 + : 15.0; + final fontHeight = 1.4; + final baseFont = theme.textTheme.bodyLarge; + final fontFamily = baseFont?.fontFamily; + final fontFamilyFallback = + baseFont?.fontFamilyFallback; + final maxLinesLimit = _isExpanded ? 25 : 5; + final verticalPadding = 8.0; + const double overlayGutter = 28.0; + // Match CodeEditor's own padding so measurement width equals real content width. + // (Otherwise the wrap threshold will drift.) + // Slightly more top padding so placeholder "输入消息与AI聊天" sits lower visually. + final contentPadding = EdgeInsets.fromLTRB( + 0, + verticalPadding, + overlayGutter, + verticalPadding / 2, + ); + + final metrics = _measureInputMetrics( + context: ctx, + text: _controller.text, + maxWidth: math.max( + 0, + constraints.maxWidth - + contentPadding.horizontal, + ), + fontSize: fontSize, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontHeight: fontHeight, + verticalPadding: contentPadding.vertical, + maxLines: maxLinesLimit, + ); + + final minHeight = + fontSize * fontHeight + + contentPadding.vertical; + final height = constraints.maxHeight.isFinite + ? math.max( + minHeight, + math.min( + metrics.height, + constraints.maxHeight, + ), + ) + : math.max(minHeight, metrics.height); + final showExpandButton = metrics.lineCount >= 3; + + return Stack( + children: [ + Focus( + onKeyEvent: _handleKeyEvent, + child: SizedBox( + height: height, + child: PlainTextCodeEditor( controller: _controller, focusNode: widget.focusNode, - onChanged: _onTextChanged, - minLines: 1, - maxLines: _isExpanded ? 25 : 5, - // On mobile, optionally show "Send" on the return key and submit on tap. - // Still keep multiline so pasted text preserves line breaks. - keyboardType: TextInputType.multiline, - textInputAction: enterToSend - ? TextInputAction.send - : TextInputAction.newline, - onSubmitted: enterToSend - ? (_) => _handleSend() - : null, - // Custom context menu: use instance method to avoid flickering - // caused by recreating the callback on every build. - // See: https://github.com/flutter/flutter/issues/150551 - contextMenuBuilder: _buildContextMenu, + toolbarController: + _buildSelectionToolbarController( + context, + ), + wordWrap: true, autofocus: false, - decoration: InputDecoration( - hintText: _hint(context), - hintStyle: TextStyle( - color: theme.colorScheme.onSurface - .withValues(alpha: 0.45), - ), - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric( - vertical: 2, - ), - ), - style: TextStyle( - color: theme.colorScheme.onSurface, - fontSize: - (Platform.isWindows || - Platform.isLinux || - Platform.isMacOS) - ? 14 - : 15, + shortcutsActivatorsBuilder: + _ChatInputShortcutsActivatorsBuilder( + useDesktopSendShortcuts: + _useDesktopSendShortcuts( + context, + ), + ), + shortcutOverrideActions: + _shortcutOverrideActions, + // Make wrapping behavior stable by ensuring we always use the same padding. + hint: _hint(context), + padding: contentPadding, + fontSize: fontSize, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontHeight: fontHeight, + hintAlpha: 0.45, + ), + ), + ), + // Expand/Collapse icon button (only shown when 3+ lines) + if (showExpandButton) + Positioned( + top: 10, + right: 12, + child: GestureDetector( + onTap: () { + setState( + () => _isExpanded = !_isExpanded, + ); + _ensureCaretVisible(); + }, + child: Icon( + _isExpanded + ? Lucide.ChevronsDownUp + : Lucide.ChevronsUpDown, + size: 16, + color: theme.colorScheme.onSurface + .withValues(alpha: 0.45), ), - cursorColor: theme.colorScheme.primary, ), - ); - }, - ), - ), - ), + ), + ], + ); + }, ), - // Expand/Collapse icon button (only shown when 3+ lines) - if (_showExpandButton) - Positioned( - top: 10, - right: 12, - child: GestureDetector( - onTap: () { - setState(() => _isExpanded = !_isExpanded); - _ensureCaretVisible(); - }, - child: Icon( - _isExpanded - ? Lucide.ChevronsDownUp - : Lucide.ChevronsUpDown, - size: 16, - color: theme.colorScheme.onSurface.withValues( - alpha: 0.45, - ), - ), - ), - ), - ], + ), ), // Bottom buttons row (no divider) Padding( @@ -1720,7 +1728,6 @@ class _ChatInputBarState extends State child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Responsive left action bar that overflows into a + menu on desktop Expanded( child: _buildResponsiveLeftActions(context), ), @@ -1831,8 +1838,7 @@ class _CompactIconButton extends StatelessWidget { final fgColor = active ? theme.colorScheme.primary : (isDark ? Colors.white70 : Colors.black54); - final bool isDesktop = - Platform.isWindows || Platform.isLinux || Platform.isMacOS; + final bool isDesktop = _isDesktopPlatform(); // Keep overall button size constant. For model icon with child, enlarge child slightly // and reduce padding so (2*padding + childSize) stays unchanged. @@ -1939,3 +1945,55 @@ class _CompactSendButton extends StatelessWidget { ); } } + +class _ComposingAwareNewLineAction extends Action { + _ComposingAwareNewLineAction({ + required this.isComposing, + required this.onInvoke, + }); + + final bool Function() isComposing; + final Object? Function(CodeShortcutNewLineIntent) onInvoke; + + @override + bool consumesKey(CodeShortcutNewLineIntent intent) => !isComposing(); + + @override + Object? invoke(CodeShortcutNewLineIntent intent) { + if (isComposing()) { + return null; + } + return onInvoke(intent); + } +} + +class _ChatInputShortcutsActivatorsBuilder + extends CodeShortcutsActivatorsBuilder { + const _ChatInputShortcutsActivatorsBuilder({ + required this.useDesktopSendShortcuts, + }); + + final bool useDesktopSendShortcuts; + + @override + List? build(CodeShortcutType type) { + if (type == CodeShortcutType.newLine) { + final activators = [ + SingleActivator(LogicalKeyboardKey.enter), + SingleActivator(LogicalKeyboardKey.numpadEnter), + SingleActivator(LogicalKeyboardKey.enter, shift: true), + SingleActivator(LogicalKeyboardKey.numpadEnter, shift: true), + ]; + if (useDesktopSendShortcuts) { + activators.addAll(const [ + SingleActivator(LogicalKeyboardKey.enter, control: true), + SingleActivator(LogicalKeyboardKey.numpadEnter, control: true), + SingleActivator(LogicalKeyboardKey.enter, meta: true), + SingleActivator(LogicalKeyboardKey.numpadEnter, meta: true), + ]); + } + return activators; + } + return const DefaultCodeShortcutsActivatorsBuilder().build(type); + } +} diff --git a/lib/features/home/widgets/chat_input_section.dart b/lib/features/home/widgets/chat_input_section.dart index 5b6461b3..10979a4a 100644 --- a/lib/features/home/widgets/chat_input_section.dart +++ b/lib/features/home/widgets/chat_input_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; import '../../../core/models/chat_input_data.dart'; import '../../../core/models/assistant.dart'; @@ -65,7 +66,7 @@ class ChatInputSection extends StatelessWidget { final GlobalKey inputBarKey; final FocusNode inputFocus; - final TextEditingController inputController; + final CodeLineEditingController inputController; final ChatInputBarController mediaController; final bool isTablet; final bool isLoading; diff --git a/lib/features/instruction_injection/pages/instruction_injection_page.dart b/lib/features/instruction_injection/pages/instruction_injection_page.dart index ee44b679..27ba4529 100644 --- a/lib/features/instruction_injection/pages/instruction_injection_page.dart +++ b/lib/features/instruction_injection/pages/instruction_injection_page.dart @@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; import '../../../icons/lucide_adapter.dart'; import '../../../l10n/app_localizations.dart'; @@ -14,6 +15,9 @@ import '../../../core/providers/instruction_injection_group_provider.dart'; import 'package:uuid/uuid.dart'; import '../../../core/services/haptics.dart'; import '../../../shared/widgets/snackbar.dart'; +import '../../../shared/widgets/input_height_constraints.dart'; +import '../../../shared/widgets/plain_text_code_editor.dart'; +import '../../../utils/re_editor_utils.dart'; class InstructionInjectionPage extends StatefulWidget { const InstructionInjectionPage({super.key}); @@ -131,6 +135,7 @@ class _InstructionInjectionPageState extends State { } if (!mounted) return; if (result == null || result.files.isEmpty) return; + if (!mounted) return; final l10n = AppLocalizations.of(context)!; final provider = context.read(); @@ -264,6 +269,7 @@ class _InstructionInjectionPageState extends State { child: groupUi.isCollapsed(groupName) ? const SizedBox.shrink() : ReorderableListView.builder( + padding: EdgeInsets.zero, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: grouped[groupName]?.length ?? 0, @@ -561,15 +567,57 @@ class InstructionInjectionEditSheet extends StatefulWidget { class _InstructionInjectionEditSheetState extends State { late final TextEditingController _titleController; + late final CodeLineEditingController _promptController; late final TextEditingController _groupController; - late final TextEditingController _promptController; @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.item?.title ?? ''); _groupController = TextEditingController(text: widget.item?.group ?? ''); - _promptController = TextEditingController(text: widget.item?.prompt ?? ''); + _promptController = CodeLineEditingController.fromText( + widget.item?.prompt ?? '', + ); + } + + @override + void didUpdateWidget(covariant InstructionInjectionEditSheet oldWidget) { + super.didUpdateWidget(oldWidget); + final oldTitle = oldWidget.item?.title ?? ''; + final newTitle = widget.item?.title ?? ''; + if (oldTitle != newTitle && _titleController.text == oldTitle) { + _syncTitleText(newTitle); + } + final oldGroup = oldWidget.item?.group ?? ''; + final newGroup = widget.item?.group ?? ''; + if (oldGroup != newGroup && _groupController.text == oldGroup) { + _syncGroupText(newGroup); + } + final oldPrompt = oldWidget.item?.prompt ?? ''; + final newPrompt = widget.item?.prompt ?? ''; + if (oldPrompt != newPrompt && _promptController.text == oldPrompt) { + _syncPromptText(newPrompt); + } + } + + void _syncTitleText(String text) { + _titleController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: TextRange.empty, + ); + } + + void _syncGroupText(String text) { + _groupController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: TextRange.empty, + ); + } + + void _syncPromptText(String text) { + _promptController.setTextSafely(text); } @override @@ -678,33 +726,46 @@ class _InstructionInjectionEditSheetState ), ), const SizedBox(height: 12), - TextField( - controller: _promptController, - maxLines: 8, - decoration: InputDecoration( - labelText: l10n.instructionInjectionPromptLabel, - alignLabelWithHint: true, - filled: true, - fillColor: isDark ? Colors.white10 : const Color(0xFFF2F3F5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 12, bottom: 6), + child: Text( + l10n.instructionInjectionPromptLabel, + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withValues(alpha: 0.6), + ), ), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), + ConstrainedBox( + constraints: buildInputMaxHeightConstraints( + context: context, + reservedHeight: 260, + softCapFraction: 0.45, + minHeight: 120, ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.5), + child: Container( + decoration: BoxDecoration( + color: isDark ? Colors.white10 : const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: _promptController, + autofocus: false, + hint: l10n.instructionInjectionPromptLabel, + padding: const EdgeInsets.all(12), + fontSize: 14, + fontHeight: 1.4, + ), ), ), - ), + ], ), const SizedBox(height: 16), Row( @@ -720,6 +781,7 @@ class _InstructionInjectionEditSheetState child: _IosFilledButton( label: l10n.quickPhraseSaveButton, onTap: () { + // TODO: Add immediate validation/UX feedback (e.g., disable Save or show error) when title or prompt is empty/invalid. Navigator.of(context).pop({ 'title': _titleController.text, 'group': _groupController.text, diff --git a/lib/features/model/pages/default_model_page.dart b/lib/features/model/pages/default_model_page.dart index 6b9a90c8..8c550593 100644 --- a/lib/features/model/pages/default_model_page.dart +++ b/lib/features/model/pages/default_model_page.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; + import '../../../core/providers/settings_provider.dart'; import '../../../icons/lucide_adapter.dart'; -import '../widgets/model_select_sheet.dart'; -import '../widgets/ocr_prompt_sheet.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import '../../../l10n/app_localizations.dart'; import '../../../utils/brand_assets.dart'; import '../../../core/services/haptics.dart'; +import '../../../shared/widgets/input_height_constraints.dart'; +import '../../../shared/widgets/plain_text_code_editor.dart'; +import '../widgets/model_select_sheet.dart'; +import '../widgets/ocr_prompt_sheet.dart'; class DefaultModelPage extends StatelessWidget { const DefaultModelPage({super.key}); @@ -168,338 +172,386 @@ class DefaultModelPage extends StatelessWidget { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final settings = context.read(); - final controller = TextEditingController(text: settings.titlePrompt); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: cs.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) { - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 12, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurface.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(999), + final controller = CodeLineEditingController.fromText(settings.titlePrompt); + try { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + ), ), ), - ), - const SizedBox(height: 12), - Text( - l10n.defaultModelPagePromptLabel, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - maxLines: 8, - decoration: InputDecoration( - hintText: l10n.defaultModelPageTitlePromptHint, - filled: true, - fillColor: Theme.of(ctx).brightness == Brightness.dark - ? Colors.white10 - : const Color(0xFFF2F3F5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - ), + const SizedBox(height: 12), + Text( + l10n.defaultModelPagePromptLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Builder( + builder: (innerCtx) { + final rawMaxPromptHeight = computeInputMaxHeight( + context: innerCtx, + reservedHeight: 220, + softCapFraction: 0.45, + minHeight: 120, + ); + final maxPromptHeight = rawMaxPromptHeight < 120 + ? 120.0 + : rawMaxPromptHeight; + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: 120, + maxHeight: maxPromptHeight, + ), + child: Container( + decoration: BoxDecoration( + color: Theme.of(ctx).brightness == Brightness.dark + ? Colors.white10 + : const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: controller, + autofocus: false, + hint: l10n.defaultModelPageTitlePromptHint, + padding: const EdgeInsets.all(12), + fontSize: 14, + fontHeight: 1.4, + ), + ), + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton( + onPressed: () async { + await settings.resetTitlePrompt(); + controller.text = settings.titlePrompt; + }, + child: Text(l10n.defaultModelPageResetDefault), ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.5), + const Spacer(), + FilledButton( + onPressed: () async { + await settings.setTitlePrompt(controller.text.trim()); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + child: Text(l10n.defaultModelPageSave), ), - ), + ], ), - ), - const SizedBox(height: 8), - Row( - children: [ - TextButton( - onPressed: () async { - await settings.resetTitlePrompt(); - controller.text = settings.titlePrompt; - }, - child: Text(l10n.defaultModelPageResetDefault), + const SizedBox(height: 6), + Text( + l10n.defaultModelPageTitleVars('{content}', '{locale}'), + style: TextStyle( + color: cs.onSurface.withValues(alpha: 0.6), + fontSize: 12, ), - const Spacer(), - FilledButton( - onPressed: () async { - await settings.setTitlePrompt(controller.text.trim()); - if (ctx.mounted) Navigator.of(ctx).pop(); - }, - child: Text(l10n.defaultModelPageSave), - ), - ], - ), - const SizedBox(height: 6), - Text( - l10n.defaultModelPageTitleVars('{content}', '{locale}'), - style: TextStyle( - color: cs.onSurface.withValues(alpha: 0.6), - fontSize: 12, ), - ), - ], + ], + ), ), - ), - ); - }, - ); + ); + }, + ); + } finally { + controller.dispose(); + } } Future _showTranslatePromptSheet(BuildContext context) async { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final settings = context.read(); - final controller = TextEditingController(text: settings.translatePrompt); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: cs.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) { - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 12, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurface.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(999), + final controller = CodeLineEditingController.fromText( + settings.translatePrompt, + ); + try { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + ), ), ), - ), - const SizedBox(height: 12), - Text( - l10n.defaultModelPagePromptLabel, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - maxLines: 8, - decoration: InputDecoration( - hintText: l10n.defaultModelPageTranslatePromptHint, - filled: true, - fillColor: Theme.of(ctx).brightness == Brightness.dark - ? Colors.white10 - : const Color(0xFFF2F3F5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - ), + const SizedBox(height: 12), + Text( + l10n.defaultModelPagePromptLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Builder( + builder: (innerCtx) { + final rawMaxPromptHeight = computeInputMaxHeight( + context: innerCtx, + reservedHeight: 220, + softCapFraction: 0.45, + minHeight: 120, + ); + final maxPromptHeight = rawMaxPromptHeight < 120 + ? 120.0 + : rawMaxPromptHeight; + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: 120, + maxHeight: maxPromptHeight, + ), + child: Container( + decoration: BoxDecoration( + color: Theme.of(ctx).brightness == Brightness.dark + ? Colors.white10 + : const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: controller, + autofocus: false, + hint: l10n.defaultModelPageTranslatePromptHint, + padding: const EdgeInsets.all(12), + fontSize: 14, + fontHeight: 1.4, + ), + ), + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton( + onPressed: () async { + await settings.resetTranslatePrompt(); + controller.text = settings.translatePrompt; + }, + child: Text(l10n.defaultModelPageResetDefault), ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.5), + const Spacer(), + FilledButton( + onPressed: () async { + await settings.setTranslatePrompt( + controller.text.trim(), + ); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + child: Text(l10n.defaultModelPageSave), ), - ), + ], ), - ), - const SizedBox(height: 8), - Row( - children: [ - TextButton( - onPressed: () async { - await settings.resetTranslatePrompt(); - controller.text = settings.translatePrompt; - }, - child: Text(l10n.defaultModelPageResetDefault), + const SizedBox(height: 6), + Text( + l10n.defaultModelPageTranslateVars( + '{source_text}', + '{target_lang}', ), - const Spacer(), - FilledButton( - onPressed: () async { - await settings.setTranslatePrompt( - controller.text.trim(), - ); - if (ctx.mounted) Navigator.of(ctx).pop(); - }, - child: Text(l10n.defaultModelPageSave), + style: TextStyle( + color: cs.onSurface.withValues(alpha: 0.6), + fontSize: 12, ), - ], - ), - const SizedBox(height: 6), - Text( - l10n.defaultModelPageTranslateVars( - '{source_text}', - '{target_lang}', - ), - style: TextStyle( - color: cs.onSurface.withValues(alpha: 0.6), - fontSize: 12, ), - ), - ], + ], + ), ), - ), - ); - }, - ); + ); + }, + ); + } finally { + controller.dispose(); + } } Future _showSummaryPromptSheet(BuildContext context) async { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final settings = context.read(); - final controller = TextEditingController(text: settings.summaryPrompt); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: cs.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) { - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 12, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurface.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(999), + final controller = CodeLineEditingController.fromText( + settings.summaryPrompt, + ); + try { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + ), ), ), - ), - const SizedBox(height: 12), - Text( - l10n.defaultModelPagePromptLabel, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - maxLines: 8, - decoration: InputDecoration( - hintText: l10n.defaultModelPageSummaryPromptHint, - filled: true, - fillColor: Theme.of(ctx).brightness == Brightness.dark - ? Colors.white10 - : const Color(0xFFF2F3F5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - ), + const SizedBox(height: 12), + Text( + l10n.defaultModelPagePromptLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Builder( + builder: (innerCtx) { + final rawMaxPromptHeight = computeInputMaxHeight( + context: innerCtx, + reservedHeight: 220, + softCapFraction: 0.45, + minHeight: 120, + ); + final maxPromptHeight = rawMaxPromptHeight < 120 + ? 120.0 + : rawMaxPromptHeight; + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: 120, + maxHeight: maxPromptHeight, + ), + child: Container( + decoration: BoxDecoration( + color: Theme.of(ctx).brightness == Brightness.dark + ? Colors.white10 + : const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: controller, + autofocus: false, + hint: l10n.defaultModelPageSummaryPromptHint, + padding: const EdgeInsets.all(12), + fontSize: 14, + fontHeight: 1.4, + ), + ), + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton( + onPressed: () async { + await settings.resetSummaryPrompt(); + controller.text = settings.summaryPrompt; + }, + child: Text(l10n.defaultModelPageResetDefault), ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.5), + const Spacer(), + FilledButton( + onPressed: () async { + await settings.setSummaryPrompt( + controller.text.trim(), + ); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + child: Text(l10n.defaultModelPageSave), ), - ), + ], ), - ), - const SizedBox(height: 8), - Row( - children: [ - TextButton( - onPressed: () async { - await settings.resetSummaryPrompt(); - controller.text = settings.summaryPrompt; - }, - child: Text(l10n.defaultModelPageResetDefault), + const SizedBox(height: 6), + Text( + l10n.defaultModelPageSummaryVars( + '{previous_summary}', + '{user_messages}', ), - const Spacer(), - FilledButton( - onPressed: () async { - await settings.setSummaryPrompt(controller.text.trim()); - if (ctx.mounted) Navigator.of(ctx).pop(); - }, - child: Text(l10n.defaultModelPageSave), + style: TextStyle( + color: cs.onSurface.withValues(alpha: 0.6), + fontSize: 12, ), - ], - ), - const SizedBox(height: 6), - Text( - l10n.defaultModelPageSummaryVars( - '{previous_summary}', - '{user_messages}', - ), - style: TextStyle( - color: cs.onSurface.withValues(alpha: 0.6), - fontSize: 12, ), - ), - ], + ], + ), ), - ), - ); - }, - ); + ); + }, + ); + } finally { + controller.dispose(); + } } Future _showCompressPromptSheet(BuildContext context) async { diff --git a/lib/features/model/widgets/ocr_prompt_sheet.dart b/lib/features/model/widgets/ocr_prompt_sheet.dart index 735c8a17..6b646469 100644 --- a/lib/features/model/widgets/ocr_prompt_sheet.dart +++ b/lib/features/model/widgets/ocr_prompt_sheet.dart @@ -1,108 +1,122 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; import '../../../core/providers/settings_provider.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../shared/widgets/input_height_constraints.dart'; +import '../../../shared/widgets/plain_text_code_editor.dart'; Future showOcrPromptSheet(BuildContext context) async { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final settings = context.read(); - final controller = TextEditingController(text: settings.ocrPrompt); + final controller = CodeLineEditingController.fromText(settings.ocrPrompt); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: cs.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) { - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 12, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurface.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(999), + try { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + ), ), ), - ), - const SizedBox(height: 12), - Text( - l10n.defaultModelPagePromptLabel, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - maxLines: 8, - decoration: InputDecoration( - hintText: l10n.defaultModelPageOcrPromptHint, - filled: true, - fillColor: Theme.of(ctx).brightness == Brightness.dark - ? Colors.white10 - : const Color(0xFFF2F3F5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - ), + const SizedBox(height: 12), + Text( + l10n.defaultModelPagePromptLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Builder( + builder: (innerCtx) { + final maxPromptHeight = computeInputMaxHeight( + context: innerCtx, + reservedHeight: 220, + softCapFraction: 0.45, + minHeight: 120, + ); + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: 120, + maxHeight: maxPromptHeight, + ), + child: Container( + decoration: BoxDecoration( + color: Theme.of(ctx).brightness == Brightness.dark + ? Colors.white10 + : const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + ), + clipBehavior: Clip.antiAlias, + child: PlainTextCodeEditor( + controller: controller, + autofocus: false, + hint: l10n.defaultModelPageOcrPromptHint, + padding: const EdgeInsets.all(12), + fontSize: 14, + fontHeight: 1.4, + ), + ), + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton( + onPressed: () async { + await settings.resetOcrPrompt(); + controller.text = settings.ocrPrompt; + }, + child: Text(l10n.defaultModelPageResetDefault), ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.primary.withValues(alpha: 0.5), + const Spacer(), + FilledButton( + onPressed: () async { + await settings.setOcrPrompt(controller.text.trim()); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + child: Text(l10n.defaultModelPageSave), ), - ), + ], ), - ), - const SizedBox(height: 8), - Row( - children: [ - TextButton( - onPressed: () async { - await settings.resetOcrPrompt(); - controller.text = settings.ocrPrompt; - }, - child: Text(l10n.defaultModelPageResetDefault), - ), - const Spacer(), - FilledButton( - onPressed: () async { - await settings.setOcrPrompt(controller.text.trim()); - if (ctx.mounted) Navigator.of(ctx).pop(); - }, - child: Text(l10n.defaultModelPageSave), - ), - ], - ), - ], + ], + ), ), - ), - ); - }, - ); + ); + }, + ); + } finally { + controller.dispose(); + } } diff --git a/lib/features/translate/pages/translate_page.dart b/lib/features/translate/pages/translate_page.dart index 722ad8f1..a2b20438 100644 --- a/lib/features/translate/pages/translate_page.dart +++ b/lib/features/translate/pages/translate_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; +import 'package:re_editor/re_editor.dart'; import '../../../icons/lucide_adapter.dart' as lucide; import '../../../l10n/app_localizations.dart'; @@ -12,10 +13,12 @@ import '../../../core/providers/assistant_provider.dart'; import '../../../core/services/api/chat_api_service.dart'; import '../../../shared/widgets/ios_tactile.dart'; import '../../../shared/widgets/snackbar.dart'; +import '../../../shared/widgets/plain_text_code_editor.dart'; import '../../settings/widgets/language_select_sheet.dart' show LanguageOption, supportedLanguages, showLanguageSelector; import '../../../core/services/haptics.dart'; import '../../model/widgets/model_select_sheet.dart' show showModelSelector; +import '../../../utils/re_editor_utils.dart'; class TranslatePage extends StatefulWidget { const TranslatePage({super.key}); @@ -25,13 +28,17 @@ class TranslatePage extends StatefulWidget { } class _TranslatePageState extends State { - final TextEditingController _src = TextEditingController(); - final TextEditingController _dst = TextEditingController(); + final CodeLineEditingController _src = CodeLineEditingController(); + final CodeLineEditingController _dst = CodeLineEditingController(); LanguageOption? _lang; String? _providerKey; String? _modelId; StreamSubscription? _sub; bool _loading = false; + int _translateRunId = 0; + Timer? _flushTimer; + StringBuffer? _pendingBuffer; + int _pendingRunId = 0; @override void initState() { @@ -41,6 +48,7 @@ class _TranslatePageState extends State { @override void dispose() { + _flushTimer?.cancel(); _sub?.cancel(); _src.dispose(); _dst.dispose(); @@ -90,7 +98,11 @@ class _TranslatePageState extends State { final lang = await showLanguageSelector(context); if (!mounted || lang == null) return; if (lang.code == '__clear__') { - setState(() => _dst.clear()); + setState(() { + _lang = null; + _dst.value = const CodeLineEditingValue.empty(); + }); + await context.read().resetTranslateTargetLang(); return; } setState(() => _lang = lang); @@ -98,9 +110,16 @@ class _TranslatePageState extends State { } Future _translate() async { - final l10n = AppLocalizations.of(context)!; final txt = _src.text.trim(); if (txt.isEmpty) return; + if (_loading) { + await _stop(); + if (!mounted) return; + } + await _sub?.cancel(); + _sub = null; + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; final pk = _providerKey; final mid = _modelId; if (pk == null || mid == null) { @@ -122,9 +141,25 @@ class _TranslatePageState extends State { setState(() { _loading = true; - _dst.text = ''; + _dst.value = const CodeLineEditingValue.empty(); }); + final runId = ++_translateRunId; + final buffer = StringBuffer(); + _registerPendingBuffer(buffer, runId); + void flushNow() { + if (!mounted || runId != _translateRunId) return; + _setOutputText(buffer.toString()); + } + + void scheduleFlush() { + if (_flushTimer?.isActive ?? false) return; + _flushTimer = Timer(const Duration(milliseconds: 80), () { + _flushTimer = null; + flushNow(); + }); + } + try { final stream = ChatApiService.sendMessageStream( config: cfg, @@ -135,18 +170,24 @@ class _TranslatePageState extends State { ); _sub = stream.listen( (chunk) { + if (runId != _translateRunId) return; final s = chunk.content; - if (_dst.text.isEmpty) { + if (buffer.isEmpty) { // Remove any leading whitespace/newlines from the first chunk to avoid top gap final cleaned = s.replaceFirst(RegExp(r'^\s+'), ''); - _dst.text = cleaned; + buffer.write(cleaned); } else { - _dst.text += s; + buffer.write(s); } + scheduleFlush(); }, onError: (e) { - if (!mounted) return; + if (!mounted || runId != _translateRunId) return; + _flushPending(); + _clearPendingBuffer(); + _sub = null; setState(() => _loading = false); + // TODO: Avoid showing raw exception details to users; log error details and show a generic message. showAppSnackBar( context, message: l10n.homePageTranslateFailed(e.toString()), @@ -154,13 +195,19 @@ class _TranslatePageState extends State { ); }, onDone: () { - if (!mounted) return; + if (!mounted || runId != _translateRunId) return; + _flushPending(); + _clearPendingBuffer(); + _sub = null; setState(() => _loading = false); }, cancelOnError: true, ); } catch (e) { + _sub = null; + if (!mounted) return; setState(() => _loading = false); + // TODO: Avoid showing raw exception details to users; log error details and show a generic message. showAppSnackBar( context, message: l10n.homePageTranslateFailed(e.toString()), @@ -169,13 +216,45 @@ class _TranslatePageState extends State { } } + void _setOutputText(String text) { + _dst.setTextSafely(text); + } + Future _stop() async { + _flushPending(); + _clearPendingBuffer(); try { await _sub?.cancel(); } catch (_) {} + _sub = null; + _translateRunId++; if (mounted) setState(() => _loading = false); } + void _registerPendingBuffer(StringBuffer buffer, int runId) { + _flushTimer?.cancel(); + _flushTimer = null; + _pendingBuffer = buffer; + _pendingRunId = runId; + } + + void _flushPending() { + final buffer = _pendingBuffer; + if (buffer == null) return; + _flushTimer?.cancel(); + _flushTimer = null; + if (mounted && _pendingRunId == _translateRunId) { + _setOutputText(buffer.toString()); + } + } + + void _clearPendingBuffer() { + _flushTimer?.cancel(); + _flushTimer = null; + _pendingBuffer = null; + _pendingRunId = 0; + } + LanguageOption? _languageForCode(String? code) { if (code == null || code.isEmpty) return null; try { @@ -232,8 +311,8 @@ class _TranslatePageState extends State { Future _clearAll() async { await _stop(); setState(() { - _src.clear(); - _dst.clear(); + _src.value = const CodeLineEditingValue.empty(); + _dst.value = const CodeLineEditingValue.empty(); }); } @@ -245,6 +324,7 @@ class _TranslatePageState extends State { final asset = (_modelId != null) ? BrandAssets.assetForName(_modelId!) : null; + final codeEditorPadding = const EdgeInsets.fromLTRB(12, 8, 12, 12); return Scaffold( appBar: AppBar( @@ -305,9 +385,11 @@ class _TranslatePageState extends State { padding: const EdgeInsets.all(8), builder: (color) { if (asset != null && asset.toLowerCase().endsWith('.svg')) { + // TODO: Add error handling/fallback UI if the brand asset path is invalid or the asset fails to load. return SvgPicture.asset(asset, width: 22, height: 22); } if (asset != null) { + // TODO: Add error handling/fallback UI if the brand asset path is invalid or the asset fails to load. return Image.asset(asset, width: 22, height: 22); } return Icon(lucide.Lucide.Bot, size: 22, color: color); @@ -326,20 +408,13 @@ class _TranslatePageState extends State { child: SizedBox( height: 200, child: _Card( - child: TextField( + child: PlainTextCodeEditor( controller: _src, - keyboardType: TextInputType.multiline, - expands: true, - maxLines: null, - minLines: null, - decoration: InputDecoration( - hintText: l10n.translatePageInputHint, - border: InputBorder.none, - contentPadding: const EdgeInsets.fromLTRB(12, 8, 12, 12), - ), - contextMenuBuilder: (context, editableTextState) => - const SizedBox.shrink(), - style: const TextStyle(fontSize: 15, height: 1.4), + autofocus: false, + hint: l10n.translatePageInputHint, + padding: codeEditorPadding, + fontSize: 15, + fontHeight: 1.4, ), ), ), @@ -349,21 +424,14 @@ class _TranslatePageState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), child: _Card( - child: TextField( + child: PlainTextCodeEditor( controller: _dst, readOnly: true, - keyboardType: TextInputType.multiline, - maxLines: null, - expands: true, - decoration: InputDecoration( - hintText: l10n.translatePageOutputHint, - border: InputBorder.none, - contentPadding: const EdgeInsets.fromLTRB(12, 8, 12, 12), - ), - enableInteractiveSelection: false, - contextMenuBuilder: (context, editableTextState) => - const SizedBox.shrink(), - style: const TextStyle(fontSize: 15, height: 1.4), + autofocus: false, + hint: l10n.translatePageOutputHint, + padding: codeEditorPadding, + fontSize: 15, + fontHeight: 1.4, ), ), ), diff --git a/lib/shared/widgets/input_height_constraints.dart b/lib/shared/widgets/input_height_constraints.dart new file mode 100644 index 00000000..70585e4b --- /dev/null +++ b/lib/shared/widgets/input_height_constraints.dart @@ -0,0 +1,62 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Build a max-height constraint for large text inputs to avoid UI overflow. +/// +/// This keeps editors readable while respecting keyboard/viewInsets and +/// surrounding UI (buttons, headers, etc.) via [reservedHeight]. +BoxConstraints buildInputMaxHeightConstraints({ + required BuildContext context, + double reservedHeight = 0, + double softCapFraction = 0.45, + double minHeight = 80, + double extraBottomPadding = 0, + bool enabled = true, +}) { + if (!enabled) return const BoxConstraints(); + final maxHeight = computeInputMaxHeight( + context: context, + reservedHeight: reservedHeight, + softCapFraction: softCapFraction, + minHeight: minHeight, + extraBottomPadding: extraBottomPadding, + ); + final safeMinHeight = math.max(0.0, minHeight); + final safeMaxHeight = maxHeight.isFinite ? math.max(0.0, maxHeight) : safeMinHeight; + final effectiveMinHeight = math.min(safeMinHeight, safeMaxHeight); + return BoxConstraints(minHeight: effectiveMinHeight, maxHeight: safeMaxHeight); +} + +/// Compute a max height for text inputs based on visible screen area. +/// +/// This expects a valid [MediaQuery]. If absent, it falls back to [minHeight] +/// to avoid exceptions and keep layout safe. +double computeInputMaxHeight({ + required BuildContext context, + double reservedHeight = 0, + double softCapFraction = 0.45, + double minHeight = 80, + double extraBottomPadding = 0, +}) { + final minCap = math.max(0.0, minHeight); + final mq = MediaQuery.maybeOf(context); + if (mq == null) { + return minCap; + } + final size = mq.size; + final viewInsets = mq.viewInsets; + final safeExtraBottomPadding = math.max(0.0, extraBottomPadding); + final visibleHeight = math.max(0.0, size.height - viewInsets.bottom - safeExtraBottomPadding); + if (visibleHeight <= 0) { + return 0; + } + final cappedFraction = softCapFraction.clamp(0.1, 0.95).toDouble(); + final softCap = visibleHeight * cappedFraction; + final safeReservedHeight = reservedHeight.isFinite ? math.max(0.0, reservedHeight) : 0.0; + final clampedReserved = math.min(safeReservedHeight, visibleHeight); + final available = visibleHeight - clampedReserved; + final capped = available > 0 ? math.min(softCap, available) : 0.0; + final candidate = math.max(minCap, capped); + return candidate.clamp(0.0, visibleHeight); +} diff --git a/lib/shared/widgets/plain_text_code_editor.dart b/lib/shared/widgets/plain_text_code_editor.dart new file mode 100644 index 00000000..23a177fd --- /dev/null +++ b/lib/shared/widgets/plain_text_code_editor.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:re_editor/re_editor.dart'; + +/// Build a common [CodeEditorStyle] for plain-text editors. +/// +/// This centralizes the typical style values used after migrating from +/// `TextField/TextEditingController` to `re_editor`'s `CodeEditor`. +CodeEditorStyle buildPlainTextCodeEditorStyle( + BuildContext context, { + double fontSize = 14, + double fontHeight = 1.4, + String? fontFamily, + List? fontFamilyFallback, + Color? textColor, + double hintAlpha = 0.5, + Color? hintTextColor, + Color? cursorColor, + double selectionAlpha = 0.3, + Color? selectionColor, + Color backgroundColor = Colors.transparent, +}) { + final cs = Theme.of(context).colorScheme; + final effectiveTextColor = textColor ?? cs.onSurface; + final effectiveHintColor = + hintTextColor ?? effectiveTextColor.withValues(alpha: hintAlpha); + final effectiveCursorColor = cursorColor ?? cs.primary; + final effectiveSelectionColor = + selectionColor ?? cs.primary.withValues(alpha: selectionAlpha); + + return CodeEditorStyle( + fontSize: fontSize, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontHeight: fontHeight, + textColor: effectiveTextColor, + hintTextColor: effectiveHintColor, + cursorColor: effectiveCursorColor, + backgroundColor: backgroundColor, + selectionColor: effectiveSelectionColor, + ); +} + +/// A thin wrapper around [CodeEditor] for plain-text editing. +/// +/// - Hides line numbers/chunk indicators via `indicatorBuilder: null` +/// - Disables code folding/highlighting via [NonCodeChunkAnalyzer] +/// - Applies a consistent theme-based [CodeEditorStyle] (customizable) +class PlainTextCodeEditor extends StatelessWidget { + const PlainTextCodeEditor({ + super.key, + required this.controller, + this.focusNode, + this.toolbarController, + this.readOnly = false, + this.autofocus = false, + this.wordWrap = true, + this.hint, + this.padding = const EdgeInsets.all(12), + this.onChanged, + this.shortcutsActivatorsBuilder, + this.shortcutOverrideActions, + this.chunkAnalyzer = const NonCodeChunkAnalyzer(), + this.style, + this.fontSize = 14, + this.fontHeight = 1.4, + this.fontFamily, + this.fontFamilyFallback, + this.textColor, + this.hintAlpha = 0.5, + this.hintTextColor, + this.cursorColor, + this.selectionAlpha = 0.3, + this.selectionColor, + this.backgroundColor = Colors.transparent, + }); + + final CodeLineEditingController controller; + final FocusNode? focusNode; + final SelectionToolbarController? toolbarController; + final bool readOnly; + final bool autofocus; + final bool wordWrap; + final String? hint; + final EdgeInsets padding; + final ValueChanged? onChanged; + + final CodeShortcutsActivatorsBuilder? shortcutsActivatorsBuilder; + final Map>? shortcutOverrideActions; + final CodeChunkAnalyzer chunkAnalyzer; + + /// Provide a fully custom style; overrides all style-related params below. + final CodeEditorStyle? style; + + // Common style params (used when [style] is null). + final double fontSize; + final double fontHeight; + final String? fontFamily; + final List? fontFamilyFallback; + final Color? textColor; + final double hintAlpha; + final Color? hintTextColor; + final Color? cursorColor; + final double selectionAlpha; + final Color? selectionColor; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + final resolvedStyle = + style ?? + buildPlainTextCodeEditorStyle( + context, + fontSize: fontSize, + fontHeight: fontHeight, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + textColor: textColor, + hintAlpha: hintAlpha, + hintTextColor: hintTextColor, + cursorColor: cursorColor, + selectionAlpha: selectionAlpha, + selectionColor: selectionColor, + backgroundColor: backgroundColor, + ); + + return CodeEditor( + controller: controller, + focusNode: focusNode, + toolbarController: toolbarController, + readOnly: readOnly, + autofocus: autofocus, + wordWrap: wordWrap, + shortcutsActivatorsBuilder: shortcutsActivatorsBuilder, + shortcutOverrideActions: shortcutOverrideActions, + indicatorBuilder: null, + chunkAnalyzer: chunkAnalyzer, + hint: hint, + padding: padding, + style: resolvedStyle, + onChanged: onChanged, + ); + } +} diff --git a/lib/utils/app_directories.dart b/lib/utils/app_directories.dart index 3f6f24da..47370890 100644 --- a/lib/utils/app_directories.dart +++ b/lib/utils/app_directories.dart @@ -58,6 +58,7 @@ class AppDirectories { /// - iOS/macOS: Caches directory /// - Windows/Linux: platform cache directory (app-specific on Linux via XDG) static Future getSystemCacheDirectory() async { + // TODO: Add error handling for getApplicationCacheDirectory; fallback to getCacheDirectory. return await getApplicationCacheDirectory(); } diff --git a/lib/utils/markdown_media_sanitizer.dart b/lib/utils/markdown_media_sanitizer.dart index c1ce801b..6e6c8417 100644 --- a/lib/utils/markdown_media_sanitizer.dart +++ b/lib/utils/markdown_media_sanitizer.dart @@ -12,6 +12,7 @@ class MarkdownMediaSanitizer { ); static Future replaceInlineBase64Images(String markdown) async { + // TODO: Harden base64 image caching (limits, atomic write, IO errors, defensive regex groups). // // Fast path: only proceed when it's clearly a base64 data image // if (!(markdown.contains('data:image/') && markdown.contains(';base64,'))) { // return markdown; @@ -31,7 +32,12 @@ class MarkdownMediaSanitizer { int last = 0; for (final m in matches) { sb.write(markdown.substring(last, m.start)); - final dataUrl = m.group(1)!; + final dataUrl = m.group(1); + if (dataUrl == null || dataUrl.isEmpty) { + sb.write(markdown.substring(m.start, m.end)); + last = m.end; + continue; + } String ext = AppDirectories.extFromMime(_mimeOf(dataUrl)); // Extract base64 payload @@ -67,6 +73,8 @@ class MarkdownMediaSanitizer { final digest = _uuid.v5(Namespace.url.value, normalized); final file = File('${dir.path}/img_$digest.$ext'); if (!await file.exists()) { + // TODO: Make file creation atomic/locked to avoid race conditions when multiple isolates write the same digest path concurrently. + // TODO: Handle IO errors (permission denied / disk full) and decide whether to fall back to original markdown or log. await file.writeAsBytes(bytes, flush: true); } diff --git a/lib/utils/markdown_preview_html.dart b/lib/utils/markdown_preview_html.dart index 56b2f4b5..a2fbb9d4 100644 --- a/lib/utils/markdown_preview_html.dart +++ b/lib/utils/markdown_preview_html.dart @@ -3,6 +3,53 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; class MarkdownPreviewHtmlBuilder { + static const String _fallbackTemplate = ''' + + + + + + + + +

+  
+
+
+''';
+
   static Future buildFromMarkdown(
     BuildContext context,
     String markdown,
@@ -17,7 +64,15 @@ class MarkdownPreviewHtmlBuilder {
     ColorScheme cs,
     String markdown,
   ) async {
-    final template = await rootBundle.loadString('assets/html/mark.html');
+    String template;
+    try {
+      template = await rootBundle.loadString('assets/html/mark.html');
+    } catch (e, s) {
+      debugPrint('Failed to load markdown HTML template: $e');
+      debugPrintStack(stackTrace: s);
+      template = _fallbackTemplate;
+    }
+    // TODO: Decide token semantics (BACKGROUND vs SURFACE, and ON_* variants). If undecided, track via a GitHub issue and reference it here.
     return template
         .replaceAll('{{MARKDOWN_BASE64}}', base64Encode(utf8.encode(markdown)))
         .replaceAll('{{BACKGROUND_COLOR}}', _toCssHex(cs.surface))
@@ -38,13 +93,22 @@ class MarkdownPreviewHtmlBuilder {
   }
 
   static String _toCssHex(Color c) {
-    int to255(double v) => (v * 255.0).round().clamp(0, 255);
-    final a = to255(c.a).toRadixString(16).padLeft(2, '0').toUpperCase();
-    final r = to255(c.r).toRadixString(16).padLeft(2, '0').toUpperCase();
-    final g = to255(c.g).toRadixString(16).padLeft(2, '0').toUpperCase();
-    final b = to255(c.b).toRadixString(16).padLeft(2, '0').toUpperCase();
-    return '#$r$g$b$a';
+    final r = _toHex(_to8Bit(c.r));
+    final g = _toHex(_to8Bit(c.g));
+    final b = _toHex(_to8Bit(c.b));
+    final a = _to8Bit(c.a);
+    if (a == 0xFF) {
+      return '#$r$g$b';
+    }
+    final aHex = _toHex(a);
+    return '#$r$g$b$aHex';
   }
+
+  static int _to8Bit(double value) =>
+      (value * 255.0).round().clamp(0, 255).toInt();
+
+  static String _toHex(int value) =>
+      value.toRadixString(16).padLeft(2, '0').toUpperCase();
 }
 
 extension Base64X on String {
diff --git a/lib/utils/re_editor_utils.dart b/lib/utils/re_editor_utils.dart
new file mode 100644
index 00000000..71396cc9
--- /dev/null
+++ b/lib/utils/re_editor_utils.dart
@@ -0,0 +1,58 @@
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:re_editor/re_editor.dart';
+
+extension CodeLineEditingControllerX on CodeLineEditingController {
+  /// Safely set editor text without losing content or breaking IME composition.
+  ///
+  /// - Skips updates while composing (IME) by default to avoid disrupting input.
+  /// - Uses [CodeLineEditingValue] so selection/composing are consistent.
+  /// - Places the cursor at the end of the content by default.
+  /// - Falls back to setting [text] directly if parsing fails.
+  void setTextSafely(
+    String nextText, {
+    bool skipIfComposing = true,
+    bool moveCursorToEnd = true,
+  }) {
+    if (skipIfComposing && isComposing) return;
+    if (text == nextText) return;
+
+    if (nextText.isEmpty) {
+      value = const CodeLineEditingValue.empty();
+      return;
+    }
+
+    try {
+      final lines = nextText.codeLines;
+      if (lines.isEmpty) {
+        // Defensive fallback; should be rare for non-empty strings.
+        text = nextText;
+        return;
+      }
+
+      CodeLineSelection selection;
+      if (moveCursorToEnd) {
+        final lastIndex = lines.length - 1;
+        final lastOffset = lines.last.length;
+        selection =
+            CodeLineSelection.collapsed(index: lastIndex, offset: lastOffset);
+      } else {
+        selection = const CodeLineSelection.collapsed(index: 0, offset: 0);
+      }
+
+      value = CodeLineEditingValue(
+        codeLines: lines,
+        selection: selection,
+        composing: TextRange.empty,
+      );
+    } catch (e, s) {
+      debugPrint('Failed to set CodeLineEditingController text: $e');
+      debugPrintStack(stackTrace: s);
+      try {
+        text = nextText;
+      } catch (_) {}
+    }
+  }
+}
+
diff --git a/lib/utils/sandbox_path_resolver.dart b/lib/utils/sandbox_path_resolver.dart
index 413b2738..5fc1299e 100644
--- a/lib/utils/sandbox_path_resolver.dart
+++ b/lib/utils/sandbox_path_resolver.dart
@@ -3,6 +3,7 @@ import 'package:path_provider/path_provider.dart';
 import 'package:flutter/foundation.dart' show debugPrint;
 import './app_directories.dart';
 
+// TODO: If Web support is required, guard dart:io usage or use conditional imports.
 /// Resolves persisted absolute file paths that include the iOS sandbox UUID
 /// to the current app container path after an app update.
 ///
diff --git a/pubspec.yaml b/pubspec.yaml
index 7628910b..db9e7756 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -94,6 +94,7 @@ dependencies:
   google_fonts: ^6.3.2
   super_clipboard: ^0.9.1
   permission_handler: ^11.3.1
+  re_editor: ^0.8.0
   flutter_tts:
     path: ./dependencies/flutter_tts
   flutter_background: ^1.3.0+1