385 lines
11 KiB
Dart
385 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({required bool embedded}) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
final BoxBorder? bodyBorder = embedded
|
|
? null
|
|
: Border.all(color: palette.border);
|
|
|
|
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: bodyBorder,
|
|
),
|
|
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(embedded: widget.embedded),
|
|
);
|
|
|
|
if (widget.embedded) {
|
|
return editor;
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: palette.cardBackground,
|
|
appBar: AppBar(title: const Text('Editar nota')),
|
|
body: SafeArea(child: editor),
|
|
);
|
|
}
|
|
}
|