import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_keyboard_size/flutter_keyboard_size.dart'; import 'package:remever/common/functions.dart'; import 'package:remever/common/resources.dart'; import 'package:remever/common/widgets/typography.dart'; import 'package:remever/common/widgets/wspace.dart'; import 'package:remever/components/extensions/context.dart'; import 'package:remever/gen/assets.gen.dart'; import 'package:remever/screens/dialogs/alert_dialog.dart'; @RoutePage() class CrudCollectionFullscreenField extends StatefulWidget { const CrudCollectionFullscreenField({ super.key, this.title = '', this.hint, this.content, this.height = 92, required this.onEditingComplete, }); final String title; final double height; final String? hint; final String? content; final void Function(String?) onEditingComplete; @override State createState() => _CrudCollectionFullscreenFieldState(); } class _CrudCollectionFullscreenFieldState extends State { final TextEditingController _controller = TextEditingController(); @override void initState() { super.initState(); if (widget.content != null) { _controller.text = widget.content!; } _controller.addListener(_handleTextChange); } @override void dispose() { _controller.removeListener(_handleTextChange); _controller.dispose(); super.dispose(); } String? _lastText; void _handleTextChange() { final currentText = _controller.text; // Если текст не изменился — выходим if (_lastText == currentText) return; // Находим позицию курсора final selection = _controller.selection; if (!selection.isValid) return; // Проверяем, был ли добавлен \n if (_lastText != null && currentText.length > _lastText!.length) { final addedChars = currentText.substring(_lastText!.length); if (addedChars == '\n') { _handleNewLineInserted(selection.start - 1); } } _lastText = currentText; } void _handleNewLineInserted(int newlinePosition) { final text = _controller.text; // Находим начало текущей строки (до \n) final lineStart = text.lastIndexOf('\n', newlinePosition - 1) + 1; final currentLine = text.substring(lineStart, newlinePosition); String? prefix; // Проверяем, начинается ли строка с префикса списка if (currentLine.startsWith('• ')) { prefix = '• '; } else { final match = RegExp(r'^(\d+)\.\s').firstMatch(currentLine); if (match != null) { final number = int.parse(match.group(1)!) + 1; prefix = '$number. '; } } // Если строка пустая (только префикс) — выходим из списка if (currentLine.trim().isEmpty && prefix != null) { _exitListAt(newlinePosition); return; } // Если есть префикс — добавляем новую строку с префиксом if (prefix != null) { final insertPos = newlinePosition + 1; final newText = text.substring(0, insertPos) + prefix + text.substring(insertPos); _controller.text = newText; // Перемещаем курсор после префикса _controller.selection = TextSelection.collapsed( offset: insertPos + prefix.length, ); return; } // Иначе — просто оставляем \n _controller.selection = TextSelection.collapsed( offset: newlinePosition + 1, ); } void _exitListAt(int newlinePosition) { final text = _controller.text; final insertPos = newlinePosition + 1; // Удаляем префикс из новой строки final lineStart = text.lastIndexOf('\n', newlinePosition - 1) + 1; final currentLine = text.substring(lineStart, newlinePosition); final trimmed = currentLine.trim(); String replacement = '\n'; if (trimmed.startsWith('•') || RegExp(r'^\d+\.').hasMatch(trimmed)) { replacement = '\n'; // просто перенос } final newText = text.substring(0, insertPos) + replacement + text.substring(insertPos); _controller.text = newText; _controller.selection = TextSelection.collapsed(offset: insertPos); } @override Widget build(BuildContext context) { return KeyboardSizeProvider( child: SafeArea( top: false, child: Scaffold( backgroundColor: AppColors.gray_bg, appBar: _buildAppBar(), body: _buildMainBody(), ), ), ); } Widget _buildMainBody() { return Stack( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16).r, child: SingleChildScrollView( physics: const BouncingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const HSpace(16), _buildField(), if (widget.hint != null) ...[ const HSpace(16), AppTypography( widget.hint!, type: Regular14px(), color: AppColors.disabled, ), ], const HSpace(50), ], ), ), ), Align(alignment: Alignment.bottomCenter, child: _buildMenu()), ], ); } Widget _buildMenu() { return Consumer( builder: (_, screenHeight, __) { return AnimatedOpacity( opacity: 1, duration: const Duration(milliseconds: 500), child: Container( height: 64.h, decoration: BoxDecoration( color: AppColors.white, border: Border( top: BorderSide(color: AppColors.gray, width: 1.w), ), ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ const WSpace(8), _buildPasteButton(), _buildCopyButton(), _buildBoldButton(), _buildH1Button(), _buildBulletListButton(), _buildNumberedListButton(), _buildSubmitButton(), const WSpace(8), ], ), ), ), ); }, ); } Widget _buildBoldButton() { return _MenuButton( icon: Icon(Icons.format_bold), onTap: () => _wrapSelection('**', '**'), ); } Widget _buildH1Button() { return _MenuButton( icon: Icon(Icons.format_italic_outlined), onTap: () => _insertAtLineStart('# '), ); } Widget _buildBulletListButton() { return _MenuButton( icon: Icon(Icons.list), onTap: () => _insertBulletList(), ); } Widget _buildNumberedListButton() { return _MenuButton( icon: Icon(Icons.list_alt_outlined), onTap: () => _insertNumberedList(), ); } void _wrapSelection(String before, String after) { final text = _controller.text; final selection = _controller.selection; if (!selection.isValid) { _controller.text = '$before${selection.textInside(text)}$after'; _controller.selection = TextSelection.collapsed( offset: _controller.text.length - after.length, ); return; } final newText = text.replaceRange( selection.start, selection.end, '$before${selection.textInside(text)}$after', ); _controller.text = newText; final cursorPos = selection.start + before.length + selection.textInside(text).length; _controller.selection = TextSelection.collapsed(offset: cursorPos); } void _insertAtLineStart(String prefix) { final text = _controller.text; final selection = _controller.selection; int start = selection.start; // Находим начало строки int lineStart = text.lastIndexOf('\n', start - 1) + 1; if (lineStart == 0 && start == 0) lineStart = 0; final newText = text.replaceRange(lineStart, lineStart, prefix); _controller.text = newText; _controller.selection = TextSelection.collapsed( offset: lineStart + prefix.length + (selection.end - selection.start), ); } void _insertBulletList() { _insertListPrefix('• '); } void _insertNumberedList() { _insertListPrefix('1. '); } void _insertListPrefix(String prefix) { final text = _controller.text; final selection = _controller.selection; String insertText; int newCursorPos; if (selection.isCollapsed) { // Вставляем в позицию курсора insertText = prefix; newCursorPos = selection.start + prefix.length; } else { // Оборачиваем выделение final selected = selection.textInside(text); final lines = selected.split('\n').where((l) => l.isNotEmpty).toList(); final prefixed = lines .mapIndexed((index, line) { if (prefix == '• ') return '$prefix$line'; final num = index + 1; return '$num. $line'; }) .join('\n'); insertText = prefixed; newCursorPos = selection.start + prefixed.length; } final newText = text.replaceRange( selection.start, selection.end, insertText, ); _controller.text = newText; _controller.selection = TextSelection.collapsed(offset: newCursorPos); } Widget _buildPasteButton() { return GestureDetector( onTap: _onPasteTap, child: Assets.icons.typePaste.image(height: 24.h, width: 24.w), ); } Future _onPasteTap() async { try { final data = await Clipboard.getData(Clipboard.kTextPlain); if (data?.text?.isEmpty ?? true) { showErrorToast('Не удалось получить текст из буфера обмена'); return; } _controller.text += ' ${data!.text}'; showSuccessToast('Текст вставлен из буфера обмена'); } catch (e) { showErrorToast('Ошибка при вставке текста: $e'); } } Widget _buildCopyButton() { return GestureDetector( onTap: _onCopyTap, child: Assets.icons.typeCopy.image(height: 24.h, width: 24.w), ); } Future _onCopyTap() async { if (_controller.text.isEmpty) { showErrorToast('Нет содержимого для отправки в буфер обмена'); return; } try { await Clipboard.setData(ClipboardData(text: _controller.text)); showSuccessToast('Текст скопирован в буфер обмена'); } catch (e) { showErrorToast('Ошибка при копировании текста: $e'); } } Widget _buildSubmitButton() { return GestureDetector( onTap: _onSubmitTap, child: SizedBox.square( dimension: 32.r, child: const DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.primary, ), child: Center( child: Icon(Icons.check, color: AppColors.white, size: 24), ), ), ), ); } void _onSubmitTap() { widget.onEditingComplete(_controller.text); context.back(); } Widget _buildField() { return SizedBox( height: widget.height.h, child: DecoratedBox( decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(12).r, ), child: Padding( padding: const EdgeInsets.all(12).r, child: TextField( autofocus: true, controller: _controller, textCapitalization: TextCapitalization.sentences, maxLines: 99, maxLength: 250, cursorColor: AppColors.danger, textInputAction: TextInputAction.newline, decoration: const InputDecoration.collapsed( hintText: 'Введите содержимое', hintStyle: TextStyle(color: AppColors.gray), ), ), ), ), ); } AppBar _buildAppBar() { return AppBar( toolbarHeight: 56.h, backgroundColor: AppColors.white, shadowColor: Colors.transparent, leading: IconButton( onPressed: () => _handleBackPress(), icon: const Icon(CupertinoIcons.left_chevron, color: Colors.black), ), centerTitle: true, title: AppTypography( widget.title, type: SemiBold20px(), color: AppColors.body_text, ), actions: [ Padding( padding: const EdgeInsets.only(right: 16).r, child: GestureDetector( onTap: _showResetDialog, child: Assets.icons.typeTrash.image( height: 24.h, width: 24.w, color: AppColors.danger, ), ), ), ], ); } Future _handleBackPress() async { final shouldExit = await _showExitDialog(); if (shouldExit ?? false) { context.back(); } } Future _showExitDialog() async { final res = await showCuperModalBottomSheet( context: context, height: 262.h, builder: (_) => const AlertInfoDialog( title: 'У вас есть несохраненные изменения', acceptTitle: 'Выйти', declineTitle: 'Сохранить и выйти', ), ); if (res == null) return false; if (res) return true; widget.onEditingComplete(_controller.text); return true; } Future _showResetDialog() async { final res = await showCuperModalBottomSheet( context: context, height: 262.h, builder: (_) => AlertInfoDialog( title: 'Удалить вcе содержимое поля "${widget.title}"?', acceptTitle: 'Удалить', declineTitle: 'Отменить', ), ); if (res == true) { _controller.clear(); } } } class _MenuButton extends StatelessWidget { final Widget icon; final VoidCallback onTap; const _MenuButton({required this.icon, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container(padding: const EdgeInsets.all(8).r, child: icon), ); } }