Files
notas/lib/screens/note_editor_screen.dart
T
2026-06-29 20:32:47 +02:00

391 lines
11 KiB
Dart

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 <Category>[],
this.embedded = false,
this.onSaved,
});
final NoteRepository? repository;
final Future<Note> Function(Note note)? saveNote;
final Note note;
final List<Category> categories;
final bool embedded;
final ValueChanged<Note>? onSaved;
@override
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends State<NoteEditorScreen> {
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>() ??
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<void> _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<void> _selectCategory(BuildContext anchorContext) async {
final Category? selected = await showMenu<Category?>(
context: anchorContext,
position: _menuRectFromContext(anchorContext),
elevation: 10,
color: _paletteOf(anchorContext).surfaceElevated,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
items: <PopupMenuEntry<Category?>>[
const PopupMenuItem<Category?>(
value: null,
child: Text('Sin categoría'),
),
const PopupMenuDivider(),
for (final Category category in widget.categories)
PopupMenuItem<Category?>(
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<String>('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),
);
}
}