diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 50d7b9e..0216109 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -40,6 +42,7 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { static const double _desktopBreakpoint = 900; + static const String _createCategoryMenuValue = '__create_category__'; final TextEditingController _searchController = TextEditingController(); final GlobalKey _filterButtonKey = GlobalKey(); @@ -182,6 +185,190 @@ class _HomeScreenState extends State { return 'Última sincronización: ${DateFormat('dd/MM/yyyy HH:mm').format(timestamp)}'; } + Future _promptCategoryName({ + required String title, + required String confirmLabel, + String? initialValue, + }) async { + final TextEditingController controller = TextEditingController( + text: initialValue ?? '', + ); + + try { + final String? result = await showDialog( + context: context, + builder: (BuildContext dialogContext) { + final AppPalette palette = _paletteOf(dialogContext); + + return AlertDialog( + backgroundColor: palette.surfaceElevated, + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + hintText: 'Nombre de la categoría', + ), + onSubmitted: (String value) { + Navigator.of(dialogContext).pop(value.trim()); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancelar'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(confirmLabel), + ), + ], + ); + }, + ); + + final String name = (result ?? '').trim(); + if (name.isEmpty) { + return null; + } + + return name; + } finally { + controller.dispose(); + } + } + + Future _saveCategory({Category? existingCategory}) async { + final String? categoryName = await _promptCategoryName( + title: existingCategory == null ? 'Crear categoría' : 'Editar categoría', + confirmLabel: existingCategory == null ? 'Crear' : 'Guardar', + initialValue: existingCategory?.name, + ); + + if (categoryName == null) { + return null; + } + + final DateTime now = DateTime.now(); + final Category category = existingCategory == null + ? Category(name: categoryName, updatedAt: now) + : existingCategory.copyWith( + name: categoryName, + updatedAt: now, + isDirty: true, + ); + + await widget.repository.createCategory(category); + + if (!mounted) { + return null; + } + + setState(() { + _categories = [ + for (final Category item in _categories) + if (item.id == category.id) category else item, + ]; + + if (!_categories.any((Category item) => item.id == category.id)) { + _categories = [..._categories, category]; + } + }); + + return category; + } + + Future _updateNoteCategory(Note note, String? categoryId) async { + try { + final Note updated = await widget.repository.updateNote( + note.copyWith( + categoryId: categoryId, + updatedAt: DateTime.now(), + isDirty: true, + ), + ); + + if (!mounted) { + return; + } + + setState(() { + _notes = [ + for (final Note item in _notes) + if (item.id == updated.id) updated else item, + ]; + }); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No se pudo cambiar la categoría: $error')), + ); + } + } + + Future _deleteNoteAfterConfirmation(Note note) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext dialogContext) { + final AppPalette palette = _paletteOf(dialogContext); + + return AlertDialog( + backgroundColor: palette.surfaceElevated, + title: const Text('Eliminar nota'), + content: const Text( + 'Esta acción eliminará la nota. ¿Quieres continuar?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancelar'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Confirmar'), + ), + ], + ); + }, + ); + + if (confirmed != true) { + return; + } + + try { + await widget.repository.deleteNote(note); + if (!mounted) { + return; + } + + setState(() { + _notes = _notes.where((Note item) => item.id != note.id).toList(); + if (_selectedNoteId == note.id) { + _selectedNoteId = null; + } + }); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No se pudo eliminar la nota: $error')), + ); + } + } + RelativeRect _menuRectFromContext(BuildContext anchorContext) { final RenderBox button = anchorContext.findRenderObject()! as RenderBox; final RenderBox overlay = @@ -224,7 +411,28 @@ class _HomeScreenState extends State { for (final Category category in _categories) PopupMenuItem( value: category.id, - child: Text(category.name), + child: Row( + children: [ + Expanded(child: Text(category.name)), + const SizedBox(width: 8), + Builder( + builder: (BuildContext menuContext) { + return IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + icon: const Icon(Icons.more_vert, size: 18), + onPressed: () { + Navigator.of(menuContext).pop(); + unawaited(_saveCategory(existingCategory: category)); + }, + ); + }, + ), + ], + ), ), ], ); @@ -248,80 +456,67 @@ class _HomeScreenState extends State { BuildContext anchorContext, Note note, ) async { - final Category? selected = await _showAnchoredCategoryMenu( + final String? selectedCategoryId = await _showAnchoredCategoryMenu( anchorContext: anchorContext, - items: >[ - const PopupMenuItem( - value: null, - child: Text('Sin categoría'), - ), + items: >[ + const PopupMenuItem(value: '', child: Text('Sin categoría')), const PopupMenuDivider(), for (final Category category in _categories) - PopupMenuItem(value: category, child: Text(category.name)), + PopupMenuItem( + value: category.id, + child: Row( + children: [ + Expanded(child: Text(category.name)), + const SizedBox(width: 8), + Builder( + builder: (BuildContext menuContext) { + return IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + icon: const Icon(Icons.more_vert, size: 18), + onPressed: () { + Navigator.of(menuContext).pop(); + unawaited(_saveCategory(existingCategory: category)); + }, + ); + }, + ), + ], + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: _createCategoryMenuValue, + child: Text('Crear categoría'), + ), ], ); - if (!mounted) { + if (!mounted || selectedCategoryId == null) { return; } - final String? categoryId = selected?.id; + if (selectedCategoryId == _createCategoryMenuValue) { + final Category? createdCategory = await _saveCategory(); + if (createdCategory == null || !mounted) { + return; + } + + await _updateNoteCategory(note, createdCategory.id); + return; + } + + final String? categoryId = selectedCategoryId.isEmpty + ? null + : selectedCategoryId; if (categoryId == note.categoryId) { return; } - try { - final Note updated = await widget.repository.updateNote( - note.copyWith( - categoryId: categoryId, - updatedAt: DateTime.now(), - isDirty: true, - ), - ); - - if (!mounted) { - return; - } - - setState(() { - _notes = [ - for (final Note item in _notes) - if (item.id == updated.id) updated else item, - ]; - }); - } catch (error) { - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('No se pudo cambiar la categoría: $error')), - ); - } - } - - Future _deleteNote(Note note) async { - try { - await widget.repository.deleteNote(note); - if (!mounted) { - return; - } - - setState(() { - _notes = _notes.where((Note item) => item.id != note.id).toList(); - if (_selectedNoteId == note.id) { - _selectedNoteId = null; - } - }); - } catch (error) { - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('No se pudo eliminar la nota: $error')), - ); - } + await _updateNoteCategory(note, categoryId); } Future _createNote({required bool openEditor}) async { @@ -387,7 +582,6 @@ class _HomeScreenState extends State { key: ValueKey(note.id), repository: widget.repository, note: note, - categories: _categories, embedded: embedded, onSaved: (Note saved) { if (!mounted) { @@ -578,22 +772,24 @@ class _HomeScreenState extends State { await _loadData(keepSelection: true); }, child: ReorderableListView.builder( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 20), + padding: const EdgeInsets.fromLTRB(10, 10, 10, 14), buildDefaultDragHandles: false, itemCount: visibleNotes.length, onReorder: _handleReorder, footer: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 88), - child: Text( - _formatLastSyncAt(), - style: TextStyle(color: palette.textSecondary, fontSize: 12), + padding: const EdgeInsets.only(top: 4, bottom: 72), + child: Center( + child: Text( + _formatLastSyncAt(), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), ), ), itemBuilder: (BuildContext context, int index) { final Note note = visibleNotes[index]; return Padding( key: ValueKey(note.id), - padding: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.only(bottom: 6), child: ReorderableDelayedDragStartListener( index: index, child: NoteCard( @@ -601,7 +797,7 @@ class _HomeScreenState extends State { isSelected: note.id == _selectedNoteId, showSelectionBorder: isDesktop, onTap: () => _handleNoteTap(note, isDesktop), - onDelete: () => _deleteNote(note), + onDelete: () => _deleteNoteAfterConfirmation(note), onChangeCategory: (BuildContext buttonContext) => _changeNoteCategory(buttonContext, note), ), @@ -682,7 +878,6 @@ class _HomeScreenState extends State { key: ValueKey(selectedNote.id), repository: widget.repository, note: selectedNote, - categories: _categories, embedded: true, onSaved: (Note saved) { if (!mounted) { diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index 9bca818..c0f67fa 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -5,10 +5,8 @@ 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({ @@ -16,7 +14,6 @@ class NoteEditorScreen extends StatefulWidget { this.repository, this.saveNote, required this.note, - this.categories = const [], this.embedded = false, this.onSaved, }); @@ -24,7 +21,6 @@ class NoteEditorScreen extends StatefulWidget { final NoteRepository? repository; final Future Function(Note note)? saveNote; final Note note; - final List categories; final bool embedded; final ValueChanged? onSaved; @@ -34,7 +30,6 @@ class NoteEditorScreen extends StatefulWidget { class _NoteEditorScreenState extends State { static const Duration _debounceDuration = Duration(seconds: 1); - final GlobalKey _categorySelectorKey = GlobalKey(); late final TextEditingController _titleController; late final QuillController _bodyController; @@ -45,7 +40,6 @@ class _NoteEditorScreenState extends State { bool _isSaving = false; bool _saveQueued = false; late Note _baselineNote; - String? _selectedCategoryId; AppPalette _paletteOf(BuildContext context) { return Theme.of(context).extension() ?? @@ -56,7 +50,6 @@ class _NoteEditorScreenState extends State { void initState() { super.initState(); _baselineNote = widget.note; - _selectedCategoryId = widget.note.categoryId; _titleController = TextEditingController(text: widget.note.title) ..addListener(_scheduleSave); _bodyController = QuillController( @@ -81,55 +74,6 @@ class _NoteEditorScreenState extends State { 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, () { @@ -147,7 +91,7 @@ class _NoteEditorScreenState extends State { final Note draft = _baselineNote.copyWith( title: title.isEmpty ? 'Sin título' : title, body: body, - categoryId: _selectedCategoryId, + categoryId: _baselineNote.categoryId, updatedAt: DateTime.now(), isDirty: true, ); @@ -188,89 +132,8 @@ class _NoteEditorScreenState extends State { } } - 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); + Widget _buildEditorBody() { 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({required bool embedded}) { - final AppPalette palette = _paletteOf(context); - final BoxBorder? bodyBorder = embedded - ? null - : Border.all(color: palette.border); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -278,36 +141,29 @@ class _NoteEditorScreenState extends State { 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, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: TextField( + controller: _titleController, + style: TextStyle( + color: palette.textPrimary, + fontSize: 26, + 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), + const SizedBox(height: 10), Expanded( child: Container( - decoration: BoxDecoration( - color: palette.transparent, - borderRadius: BorderRadius.circular(16), - border: bodyBorder, - ), - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(8), child: QuillEditor.basic( controller: _bodyController, focusNode: _bodyFocusNode, @@ -323,7 +179,7 @@ class _NoteEditorScreenState extends State { ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), QuillSimpleToolbar( controller: _bodyController, config: const QuillSimpleToolbarConfig( @@ -367,8 +223,8 @@ class _NoteEditorScreenState extends State { final AppPalette palette = _paletteOf(context); final Widget editor = Padding( - padding: const EdgeInsets.all(20), - child: _buildEditorBody(embedded: widget.embedded), + padding: const EdgeInsets.all(8), + child: _buildEditorBody(), ); if (widget.embedded) { diff --git a/lib/widgets/note_card.dart b/lib/widgets/note_card.dart index 85b0be2..06853e0 100644 --- a/lib/widgets/note_card.dart +++ b/lib/widgets/note_card.dart @@ -49,7 +49,7 @@ class NoteCard extends StatelessWidget { highlightColor: Colors.transparent, // Desactiva el brillo al mantener pulsado child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -75,38 +75,51 @@ class NoteCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( color: palette.textSecondary, - fontSize: 13, + fontSize: 14, height: 1.2, ), ), + const SizedBox(height: 4), ], ), ), const SizedBox(width: 8), - PopupMenuButton( - tooltip: 'Más opciones', - icon: Icon(Icons.more_vert, color: palette.textSecondary), - onOpened: () {}, - onSelected: (String value) { - switch (value) { - case 'delete': - onDelete?.call(); - return; - case 'category': - onChangeCategory?.call(context); - return; - } + Builder( + builder: (BuildContext buttonContext) { + return PopupMenuButton( + icon: Icon(Icons.more_vert, color: palette.textSecondary), + onSelected: (String value) { + switch (value) { + case 'category': + onChangeCategory?.call(buttonContext); + return; + case 'delete': + onDelete?.call(); + return; + } + }, + itemBuilder: (BuildContext context) => + >[ + const PopupMenuItem( + value: 'category', + child: Text('Modificar categoría'), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: const [ + Icon(Icons.delete_outline, color: Colors.red), + SizedBox(width: 10), + Text( + 'Eliminar', + style: TextStyle(color: Colors.red), + ), + ], + ), + ), + ], + ); }, - itemBuilder: (BuildContext context) => >[ - const PopupMenuItem( - value: 'delete', - child: Text('Eliminar nota'), - ), - const PopupMenuItem( - value: 'category', - child: Text('Cambiar categoría'), - ), - ], ), ], ), diff --git a/test/note_editor_screen_test.dart b/test/note_editor_screen_test.dart index 83ffb24..5755cc7 100644 --- a/test/note_editor_screen_test.dart +++ b/test/note_editor_screen_test.dart @@ -33,13 +33,6 @@ void main() { body: NoteEditorScreen( repository: null, note: initialNote, - categories: [ - Category( - id: 'work', - name: 'Trabajo', - updatedAt: DateTime(2026, 5, 21), - ), - ], saveNote: (Note note) async => note, onSaved: (Note result) { savedNote = result; @@ -88,7 +81,6 @@ void main() { body: NoteEditorScreen( repository: null, note: initialNote, - categories: [], saveNote: (Note note) async { saveCount += 1; return note;