/
siblun
/
ReadFlow
Обзор
Документация
Войти
/
siblun
/
ReadFlow
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
lib/presentation/widgets/note_editor_widget.dart
298 строк
10 KB
siblun
Refactoring
05 май 2026, 05:44
05 май 2026, 05:44
c76c9b2
Код
Авторство
О чём код?
import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../domain/models/note_model.dart'; import '../theme/app_colors.dart'; import '../theme/app_typography.dart'; class NoteEditorConfig { final String? initialText; final String? initialLocation; final bool initialIsQuote; final String? hintText; final bool showLocationField; final bool showQuoteToggle; final double? editorHeight; final bool showError; const NoteEditorConfig({ this.initialText, this.initialLocation, this.initialIsQuote = false, this.hintText = 'Что интересного произошло?', this.showLocationField = true, this.showQuoteToggle = true, this.editorHeight, this.showError = false, }); } /// Универсальный виджет редактирования заметок и цитат. /// /// Технические особенности: /// - **Конфигурируемость**: Через [NoteEditorConfig] можно скрывать поля локации, /// переключатель цитаты или менять высоту текстового поля. /// - **Реактивность**: Использует [_notifyChanges] для передачи данных «вверх» /// родительскому виджету при каждом изменении текста или статуса цитаты. /// - **UX**: Автоматически очищает фокус и предоставляет методы [clear] и [getEditorData] /// для внешнего управления состоянием. /// - **Адаптивность**: Использует [LayoutBuilder] и расчеты через [1.sw] для /// корректного отображения отступов на узких экранах. class NoteEditorWidget extends StatefulWidget { final NoteEditorConfig config; final ValueChanged<NoteEditorData> onChanged; const NoteEditorWidget({ super.key, required this.config, required this.onChanged, }); @override State<NoteEditorWidget> createState() => _NoteEditorWidgetState(); } class _NoteEditorWidgetState extends State<NoteEditorWidget> { late TextEditingController _textController; late TextEditingController _locationController; late bool _isQuote; late FocusNode _textFocusNode; late FocusNode _locationFocusNode; late bool showError; @override void initState() { super.initState(); _isQuote = widget.config.initialIsQuote; _textFocusNode = FocusNode(); _locationFocusNode = FocusNode(); _textController = TextEditingController(text: widget.config.initialText ?? ''); _textController.addListener(_notifyChanges); _locationController = TextEditingController(text: widget.config.initialLocation ?? ''); _locationController.addListener(_notifyChanges); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _notifyChanges(); }); } void _notifyChanges() { if (!mounted) return; widget.onChanged(NoteEditorData( text: _textController.text.trim(), location: _locationController.text.trim().isEmpty ? null : _locationController.text.trim(), isQuote: _isQuote, )); } @override void dispose() { _textFocusNode.dispose(); _locationFocusNode.dispose(); _textController.dispose(); _locationController.dispose(); super.dispose(); } NoteEditorData getEditorData() { return NoteEditorData( text: _textController.text.trim(), location: _locationController.text.trim().isEmpty ? null : _locationController.text.trim(), isQuote: _isQuote, ); } void clear() { _textController.clear(); _locationController.clear(); setState(() => _isQuote = false); _notifyChanges(); } @override Widget build(BuildContext context) { final horizontalPadding = 1.sw < 360 ? 16.w : 24.w; return LayoutBuilder( builder: (context, constraints) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (widget.config.showQuoteToggle) _buildHeader(horizontalPadding), if (widget.config.showLocationField) ...[ SizedBox(height: 12.h), _buildLocationField(horizontalPadding), ], SizedBox(height: 16.h), _buildEditorBlock(horizontalPadding), ], ); }, ); } Widget _buildHeader(double padding) { return Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: Wrap( alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, spacing: 8.w, runSpacing: 8.h, children: [ ConstrainedBox( constraints: BoxConstraints(maxWidth: 0.6.sw), child: Text( widget.config.hintText!, style: AppTypography.h3.copyWith( fontSize: 18.sp, fontWeight: FontWeight.w600, color: AppColors.primaryAction, ), ), ), _buildQuoteToggle(), ], ), ); } Widget _buildQuoteToggle() { return GestureDetector( onTap: () { setState(() => _isQuote = !_isQuote); _notifyChanges(); }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), decoration: BoxDecoration( color: _isQuote ? AppColors.primaryAction : Colors.transparent, borderRadius: BorderRadius.circular(20.r), border: Border.all( color: AppColors.primaryAction.withValues(alpha: 0.2), ), ), child: Text( 'Цитата', style: AppTypography.label.copyWith( fontSize: 12.sp, fontWeight: FontWeight.w500, color: _isQuote ? AppColors.background : AppColors.accentSecondary, ), ), ), ); } Widget _buildLocationField(double padding) { return Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: TextField( controller: _locationController, focusNode: _locationFocusNode, style: AppTypography.bodyMedium.copyWith(fontSize: 14.sp), decoration: InputDecoration( labelText: 'Где это в книге?', labelStyle: AppTypography.label.copyWith(fontSize: 12.sp), filled: true, fillColor: AppColors.surface.withValues(alpha: 0.3), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12.r), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12.r), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12.r), borderSide: BorderSide.none, ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12.r), borderSide: BorderSide.none, ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12.r), borderSide: BorderSide.none, ), prefixIcon: Icon(Icons.bookmark_outline_rounded, size: 18.w), contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), ), ), ); } Widget _buildEditorBlock(double padding) { final bool hasError = widget.config.showError && _textController.text.isEmpty; return Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: Container( decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(16.r), border: hasError ? Border.all(color: AppColors.error, width: 1.w) : null, ), constraints: widget.config.editorHeight != null ? BoxConstraints(minHeight: widget.config.editorHeight!.h) : null, padding: EdgeInsets.all(12.w), child: TextField( controller: _textController, focusNode: _textFocusNode, maxLines: null, minLines: 5, expands: widget.config.editorHeight == null, textAlignVertical: TextAlignVertical.top, style: AppTypography.bodyMedium.copyWith(fontSize: 14.sp, height: 1.4), decoration: InputDecoration( errorText: hasError ? 'Поле не может быть пустым' : null, errorStyle: TextStyle(fontSize: 10.sp, color: AppColors.error), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, hintText: 'Введите текст...', hintStyle: AppTypography.bodyMedium.copyWith( fontSize: 14.sp, color: AppColors.accentSecondary, ), contentPadding: EdgeInsets.zero, ), ), ), ); } } /// Промежуточный класс для передачи данных из текстовых полей в бизнес-логику. /// /// Содержит фабричный метод [toNote], который преобразует данные редактора /// в полноценную доменную модель [Note], готовую к сохранению в базу данных. class NoteEditorData { final String text; final String? location; final bool isQuote; NoteEditorData({ required this.text, this.location, this.isQuote = false, }); Note toNote(String bookId) { return Note( id: DateTime.now().millisecondsSinceEpoch.toString(), bookId: bookId, text: text, location: (location != null && location!.trim().isNotEmpty) ? location : null, createdAt: DateTime.now(), isQuote: isQuote, ); } }