import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:notas/data/note_body.dart'; import 'package:notas/data/note_repository.dart'; import 'package:notas/models/category.dart'; import 'package:notas/models/note.dart'; import 'package:notas/theme/app_palette.dart'; import 'package:notas/widgets/category_style.dart'; class NoteEditorScreen extends StatefulWidget { const NoteEditorScreen({ super.key, this.repository, this.saveNote, required this.note, this.categories = const [], this.embedded = false, this.onSaved, }); final NoteRepository? repository; final Future Function(Note note)? saveNote; final Note note; final List categories; final bool embedded; final ValueChanged? onSaved; @override State createState() => _NoteEditorScreenState(); } class _NoteEditorScreenState extends State { static const Duration _debounceDuration = Duration(seconds: 1); final GlobalKey _categorySelectorKey = GlobalKey(); late final TextEditingController _titleController; late final QuillController _bodyController; late final FocusNode _bodyFocusNode; late final ScrollController _bodyScrollController; Timer? _debounceTimer; bool _isSaving = false; bool _saveQueued = false; late Note _baselineNote; String? _selectedCategoryId; AppPalette _paletteOf(BuildContext context) { return Theme.of(context).extension() ?? AppPalette.fromBrightness(Theme.of(context).brightness); } @override void initState() { super.initState(); _baselineNote = widget.note; _selectedCategoryId = widget.note.categoryId; _titleController = TextEditingController(text: widget.note.title) ..addListener(_scheduleSave); _bodyController = QuillController( document: noteBodyToDocument(widget.note.body), selection: const TextSelection.collapsed(offset: 0), )..addListener(_scheduleSave); _bodyFocusNode = FocusNode(); _bodyScrollController = ScrollController(); } @override void dispose() { _debounceTimer?.cancel(); _titleController.dispose(); _bodyController.dispose(); _bodyFocusNode.dispose(); _bodyScrollController.dispose(); super.dispose(); } String _bodyAsJson() { return noteDocumentToStorageJson(_bodyController.document); } Category? _categoryById(String? id) { for (final Category category in widget.categories) { if (category.id == id) { return category; } } return null; } Color _categoryBackgroundColor(Category? category) { final AppPalette palette = _paletteOf(context); if (category?.colorValue == null) { return palette.borderMuted; } return Color(category!.colorValue!); } Color _categoryForegroundColor(Category? category) { final AppPalette palette = _paletteOf(context); if (category == null || category.colorValue == null) { return palette.textPrimary; } final Color background = Color(category.colorValue!); return background.computeLuminance() > 0.55 ? palette.textOnSurfaceDark : palette.textPrimary; } RelativeRect _menuRectFromContext(BuildContext anchorContext) { final RenderBox button = anchorContext.findRenderObject()! as RenderBox; final RenderBox overlay = Overlay.of(anchorContext).context.findRenderObject()! as RenderBox; final Offset topLeft = button.localToGlobal(Offset.zero, ancestor: overlay); final Offset bottomRight = button.localToGlobal( button.size.bottomRight(Offset.zero), ancestor: overlay, ); return RelativeRect.fromRect( Rect.fromLTRB( topLeft.dx, topLeft.dy, bottomRight.dx, bottomRight.dy, ), Offset.zero & overlay.size, ); } void _scheduleSave() { _debounceTimer?.cancel(); _debounceTimer = Timer(_debounceDuration, () { unawaited(_saveNow()); }); } Future _saveNow() async { if (!mounted) { return; } final String title = _titleController.text.trim(); final String body = _bodyAsJson(); final Note draft = _baselineNote.copyWith( title: title.isEmpty ? 'Sin título' : title, body: body, categoryId: _selectedCategoryId, updatedAt: DateTime.now(), isDirty: true, ); final bool hasChanges = draft.title != _baselineNote.title || draft.body != _baselineNote.body || draft.categoryId != _baselineNote.categoryId; if (!hasChanges) { return; } if (_isSaving) { _saveQueued = true; return; } _isSaving = true; try { final Note saved = widget.saveNote != null ? await widget.saveNote!(draft) : await widget.repository!.updateNote(draft); _baselineNote = saved; widget.onSaved?.call(saved); } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('No se pudo guardar la nota: $error')), ); } } finally { _isSaving = false; if (_saveQueued) { _saveQueued = false; unawaited(_saveNow()); } } } Future _selectCategory(BuildContext anchorContext) async { final Category? selected = await showMenu( context: anchorContext, position: _menuRectFromContext(anchorContext), elevation: 10, color: _paletteOf(anchorContext).surfaceElevated, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), items: >[ const PopupMenuItem( value: null, child: Text('Sin categoría'), ), const PopupMenuDivider(), for (final Category category in widget.categories) PopupMenuItem( value: category, child: Text(category.name), ), ], ); if (!mounted) { return; } setState(() { _selectedCategoryId = selected?.id; }); _scheduleSave(); } Widget _buildCategorySelector() { final Category? category = _categoryById(_selectedCategoryId); final AppPalette palette = _paletteOf(context); final Color backgroundColor = _categoryBackgroundColor(category); final Color foregroundColor = _categoryForegroundColor(category); return InkWell( key: const ValueKey('category_selector'), borderRadius: BorderRadius.circular(12), onTap: () => _selectCategory(context), child: Container( key: _categorySelectorKey, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: category?.colorValue != null ? backgroundColor.withValues(alpha: 0.9) : palette.textDisabled, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( CategoryStyle.iconForCodePoint(category?.iconCodePoint), color: foregroundColor, size: 16, ), const SizedBox(width: 8), Flexible( child: Text( category?.name ?? 'Sin categoría', overflow: TextOverflow.ellipsis, style: TextStyle( color: foregroundColor, fontSize: 12, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 6), Icon(Icons.arrow_drop_down, color: foregroundColor, size: 18), ], ), ), ); } Widget _buildEditorBody() { final AppPalette palette = _paletteOf(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: TextField( controller: _titleController, style: TextStyle( color: palette.textPrimary, fontSize: 28, fontWeight: FontWeight.w700, ), decoration: InputDecoration( hintText: 'Título', hintStyle: TextStyle(color: palette.textHint), border: InputBorder.none, ), ), ), const SizedBox(width: 12), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 240), child: _buildCategorySelector(), ), ], ), const SizedBox(height: 16), Expanded( child: Container( decoration: BoxDecoration( color: palette.transparent, borderRadius: BorderRadius.circular(16), border: Border.all(color: palette.border), ), padding: const EdgeInsets.all(14), child: QuillEditor.basic( controller: _bodyController, focusNode: _bodyFocusNode, scrollController: _bodyScrollController, config: QuillEditorConfig( scrollable: true, padding: EdgeInsets.zero, autoFocus: false, expands: true, placeholder: 'Escribe tu nota...', keyboardAppearance: Theme.of(context).brightness, ), ), ), ), const SizedBox(height: 12), QuillSimpleToolbar( controller: _bodyController, config: const QuillSimpleToolbarConfig( color: Colors.transparent, showBoldButton: true, showItalicButton: true, showUnderLineButton: true, showStrikeThrough: false, showInlineCode: false, showColorButton: false, showBackgroundColorButton: false, showClearFormat: false, showAlignmentButtons: false, showHeaderStyle: false, showListNumbers: true, showListBullets: true, showListCheck: true, showCodeBlock: false, showQuote: false, showIndent: false, showLink: false, showUndo: false, showRedo: false, showDividers: false, showFontFamily: false, showFontSize: false, showDirection: false, showSearchButton: false, showSubscript: false, showSuperscript: false, multiRowsDisplay: false, axis: Axis.horizontal, ), ), ], ); } @override Widget build(BuildContext context) { final AppPalette palette = _paletteOf(context); final Widget editor = Padding( padding: const EdgeInsets.all(20), child: _buildEditorBody(), ); if (widget.embedded) { return Container(color: palette.cardBackground, child: editor); } return Scaffold( backgroundColor: palette.cardBackground, appBar: AppBar( title: const Text('Editar nota'), ), body: SafeArea(child: editor), ); } }