Files
Remever/lib/screens/crud_collection/widgets/crud_collection_fullscreen_field.dart

523 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CrudCollectionFullscreenField> createState() =>
_CrudCollectionFullscreenFieldState();
}
class _CrudCollectionFullscreenFieldState
extends State<CrudCollectionFullscreenField> {
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<ScreenHeight>(
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<void> _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<void> _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<void> _handleBackPress() async {
final shouldExit = await _showExitDialog();
if (shouldExit ?? false) {
context.back();
}
}
Future<bool?> _showExitDialog() async {
final res = await showCuperModalBottomSheet<bool>(
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<void> _showResetDialog() async {
final res = await showCuperModalBottomSheet<bool>(
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),
);
}
}