From f662e59547988181f21174e6798dfa7d293a237e Mon Sep 17 00:00:00 2001 From: Marck64 Date: Thu, 2 Jul 2026 12:52:41 +0200 Subject: [PATCH] refactor: Enhance category handling in note editor and card components --- lib/screens/home_screen.dart | 474 ++++++++++++++++++++++------ lib/screens/note_editor_screen.dart | 25 +- lib/widgets/note_card.dart | 52 ++- 3 files changed, 445 insertions(+), 106 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0216109..0d4ba47 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -9,9 +9,22 @@ import 'package:notas/models/category.dart'; import 'package:notas/models/note.dart'; import 'package:notas/screens/note_editor_screen.dart'; import 'package:notas/theme/app_palette.dart'; +import 'package:notas/widgets/category_style.dart'; import 'package:notas/widgets/note_card.dart'; import 'package:notas/widgets/sync_status.dart'; +class _CategoryDraft { + const _CategoryDraft({ + required this.name, + required this.colorValue, + required this.iconCodePoint, + }); + + final String name; + final int colorValue; + final int iconCodePoint; +} + class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, @@ -176,6 +189,20 @@ class _HomeScreenState extends State { return null; } + Category? _categoryById(String? categoryId) { + if (categoryId == null) { + return null; + } + + for (final Category category in _categories) { + if (category.id == categoryId) { + return category; + } + } + + return null; + } + String _formatLastSyncAt() { final DateTime? timestamp = _lastSyncAt; if (timestamp == null) { @@ -185,80 +212,253 @@ class _HomeScreenState extends State { return 'Última sincronización: ${DateFormat('dd/MM/yyyy HH:mm').format(timestamp)}'; } - Future _promptCategoryName({ + Future<_CategoryDraft?> _promptCategoryDetails({ required String title, required String confirmLabel, - String? initialValue, + String? initialName, + int? initialColorValue, + int? initialIconCodePoint, }) async { final TextEditingController controller = TextEditingController( - text: initialValue ?? '', + text: initialName ?? '', ); + final List colorOptions = CategoryStyle.colorsOf(context); + final List iconOptions = CategoryStyle.icons; + final int fallbackColorValue = + initialColorValue ?? colorOptions.first.toARGB32(); + final int fallbackIconCodePoint = + initialIconCodePoint ?? iconOptions.first.codePoint; try { - final String? result = await showDialog( + final _CategoryDraft? result = await showDialog<_CategoryDraft>( context: context, builder: (BuildContext dialogContext) { final AppPalette palette = _paletteOf(dialogContext); + final List dialogColorOptions = CategoryStyle.colorsOf( + dialogContext, + ); + final List dialogIconOptions = CategoryStyle.icons; - 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), - ), - ], + int selectedColorValue = fallbackColorValue; + int selectedIconCodePoint = fallbackIconCodePoint; + + return StatefulBuilder( + builder: (BuildContext context, StateSetter setDialogState) { + final Color previewColor = Color(selectedColorValue); + + return AlertDialog( + backgroundColor: palette.surfaceElevated, + title: Text(title), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + hintText: 'Nombre de la categoría', + ), + onSubmitted: (String value) { + final String name = value.trim(); + if (name.isEmpty) { + return; + } + + Navigator.of(dialogContext).pop( + _CategoryDraft( + name: name, + colorValue: selectedColorValue, + iconCodePoint: selectedIconCodePoint, + ), + ); + }, + ), + const SizedBox(height: 20), + Text( + 'Color', + style: TextStyle( + color: palette.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final Color color in dialogColorOptions) + InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () { + setDialogState(() { + selectedColorValue = color.toARGB32(); + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 42, + height: 42, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all( + color: + selectedColorValue == color.toARGB32() + ? palette.textPrimary + : palette.border, + width: + selectedColorValue == color.toARGB32() + ? 2.5 + : 1, + ), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.18), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: selectedColorValue == color.toARGB32() + ? Icon( + Icons.check, + size: 18, + color: color.computeLuminance() > 0.5 + ? Colors.black + : Colors.white, + ) + : null, + ), + ), + ], + ), + const SizedBox(height: 20), + Text( + 'Icono', + style: TextStyle( + color: palette.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final IconData icon in dialogIconOptions) + InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () { + setDialogState(() { + selectedIconCodePoint = icon.codePoint; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 50, + height: 50, + decoration: BoxDecoration( + color: selectedIconCodePoint == icon.codePoint + ? previewColor.withOpacity(0.14) + : palette.fill, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: + selectedIconCodePoint == icon.codePoint + ? previewColor + : palette.border, + width: + selectedIconCodePoint == icon.codePoint + ? 2 + : 1, + ), + ), + child: Icon( + icon, + color: selectedIconCodePoint == icon.codePoint + ? previewColor + : palette.textSecondary, + ), + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancelar'), + ), + FilledButton( + onPressed: () { + final String name = controller.text.trim(); + if (name.isEmpty) { + return; + } + + Navigator.of(dialogContext).pop( + _CategoryDraft( + name: name, + colorValue: selectedColorValue, + iconCodePoint: selectedIconCodePoint, + ), + ); + }, + child: Text(confirmLabel), + ), + ], + ); + }, ); }, ); - final String name = (result ?? '').trim(); - if (name.isEmpty) { + if (result == null || result.name.trim().isEmpty) { return null; } - return name; + return result; } finally { controller.dispose(); } } Future _saveCategory({Category? existingCategory}) async { - final String? categoryName = await _promptCategoryName( + final _CategoryDraft? categoryDraft = await _promptCategoryDetails( title: existingCategory == null ? 'Crear categoría' : 'Editar categoría', confirmLabel: existingCategory == null ? 'Crear' : 'Guardar', - initialValue: existingCategory?.name, + initialName: existingCategory?.name, + initialColorValue: + existingCategory?.colorValue ?? + CategoryStyle.colorsOf(context).first.toARGB32(), + initialIconCodePoint: + existingCategory?.iconCodePoint ?? + CategoryStyle.icons.first.codePoint, ); - if (categoryName == null) { + if (categoryDraft == null) { return null; } final DateTime now = DateTime.now(); final Category category = existingCategory == null - ? Category(name: categoryName, updatedAt: now) + ? Category( + name: categoryDraft.name, + updatedAt: now, + colorValue: categoryDraft.colorValue, + iconCodePoint: categoryDraft.iconCodePoint, + ) : existingCategory.copyWith( - name: categoryName, + name: categoryDraft.name, updatedAt: now, isDirty: true, + colorValue: categoryDraft.colorValue, + iconCodePoint: categoryDraft.iconCodePoint, ); await widget.repository.createCategory(category); @@ -399,39 +599,94 @@ class _HomeScreenState extends State { ); } + Widget _buildCategoryMenuItem({ + required BuildContext context, + required String label, + required IconData icon, + required Color color, + required bool selected, + VoidCallback? onEditPressed, + }) { + final AppPalette palette = _paletteOf(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: selected ? color.withOpacity(0.05) : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? color.withOpacity(0.42) : Colors.transparent, + width: selected ? 1 : 1, + ), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? palette.textPrimary : palette.textSecondary, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + if (onEditPressed != null) + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon( + Icons.more_vert, + size: 18, + color: palette.textSecondary, + ), + onPressed: onEditPressed, + ), + ], + ), + ); + } + Future _openCategoryFilter(BuildContext anchorContext) async { final String? selectedCategoryId = await _showAnchoredCategoryMenu( anchorContext: anchorContext, items: >[ - const PopupMenuItem( + PopupMenuItem( value: '', - child: Text('Todas las categorías'), + child: _buildCategoryMenuItem( + context: anchorContext, + label: 'Todas las categorías', + icon: Icons.filter_alt_outlined, + color: _paletteOf(anchorContext).textSecondary, + selected: _selectedCategoryId == null, + ), ), const PopupMenuDivider(), for (final Category category in _categories) 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)); - }, - ); + child: Builder( + builder: (BuildContext menuContext) { + final Color categoryColor = Color( + category.colorValue ?? + _paletteOf(menuContext).accent.toARGB32(), + ); + return _buildCategoryMenuItem( + context: menuContext, + label: category.name, + icon: CategoryStyle.iconForCodePoint(category.iconCodePoint), + color: categoryColor, + selected: _selectedCategoryId == category.id, + onEditPressed: () { + Navigator.of(menuContext).pop(); + unawaited(_saveCategory(existingCategory: category)); }, - ), - ], + ); + }, ), ), ], @@ -459,38 +714,50 @@ class _HomeScreenState extends State { final String? selectedCategoryId = await _showAnchoredCategoryMenu( anchorContext: anchorContext, items: >[ - const PopupMenuItem(value: '', child: Text('Sin categoría')), + PopupMenuItem( + value: '', + child: _buildCategoryMenuItem( + context: anchorContext, + label: 'Sin categoría', + icon: Icons.folder_outlined, + color: _paletteOf(anchorContext).textSecondary, + selected: note.categoryId == null, + ), + ), const PopupMenuDivider(), for (final Category category in _categories) 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)); - }, - ); + child: Builder( + builder: (BuildContext menuContext) { + final Color categoryColor = Color( + category.colorValue ?? + _paletteOf(menuContext).accent.toARGB32(), + ); + return _buildCategoryMenuItem( + context: menuContext, + label: category.name, + icon: CategoryStyle.iconForCodePoint(category.iconCodePoint), + color: categoryColor, + selected: note.categoryId == category.id, + onEditPressed: () { + Navigator.of(menuContext).pop(); + unawaited(_saveCategory(existingCategory: category)); }, - ), - ], + ); + }, ), ), const PopupMenuDivider(), - const PopupMenuItem( + PopupMenuItem( value: _createCategoryMenuValue, - child: Text('Crear categoría'), + child: _buildCategoryMenuItem( + context: anchorContext, + label: 'Crear categoría', + icon: Icons.add_circle_outline, + color: _paletteOf(anchorContext).textSecondary, + selected: false, + ), ), ], ); @@ -673,11 +940,36 @@ class _HomeScreenState extends State { return IconButton( key: _filterButtonKey, onPressed: () => _openCategoryFilter(buttonContext), - icon: Icon( - Icons.filter_alt_outlined, - color: palette.textSecondary, - ), tooltip: 'Filtrar por categorías', + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: _selectedCategoryId == null + ? Colors.transparent + : palette.accent.withOpacity(0.08), + shape: const CircleBorder(), + ), + icon: Stack( + clipBehavior: Clip.none, + children: [ + Icon( + Icons.filter_alt_outlined, + color: palette.textSecondary, + ), + if (_selectedCategoryId != null) + Positioned( + right: -1, + top: -1, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: palette.accent, + shape: BoxShape.circle, + ), + ), + ), + ], + ), ); }, ), @@ -794,6 +1086,7 @@ class _HomeScreenState extends State { index: index, child: NoteCard( note: note, + category: _categoryById(note.categoryId), isSelected: note.id == _selectedNoteId, showSelectionBorder: isDesktop, onTap: () => _handleNoteTap(note, isDesktop), @@ -812,14 +1105,9 @@ class _HomeScreenState extends State { final AppPalette palette = _paletteOf(context); return Container( - decoration: BoxDecoration( - color: palette.surfaceElevated, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(18), - ), child: Center( child: Text( - 'Selecciona una nota o crea una nueva para empezar', + 'Selecciona una nota o\ncrea una nueva para empezar.', textAlign: TextAlign.center, style: TextStyle( color: palette.textSecondary, diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index c0f67fa..d0b1dfe 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -231,10 +231,27 @@ class _NoteEditorScreenState extends State { return editor; } - return Scaffold( - backgroundColor: palette.cardBackground, - appBar: AppBar(title: const Text('Editar nota')), - body: SafeArea(child: editor), + return Container( + decoration: BoxDecoration(gradient: palette.backdropGradient), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + title: const Text('Editar nota'), + backgroundColor: Colors.transparent, + elevation: 0, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: palette.border, width: 0.5), + ), + ), + ), + ), + ), + body: SafeArea(child: editor), + ), ); } } diff --git a/lib/widgets/note_card.dart b/lib/widgets/note_card.dart index 06853e0..5591904 100644 --- a/lib/widgets/note_card.dart +++ b/lib/widgets/note_card.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:notas/data/note_body.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 NoteCard extends StatelessWidget { const NoteCard({ super.key, required this.note, + this.category, this.isSelected = false, this.borderColor, this.onTap, @@ -17,6 +20,7 @@ class NoteCard extends StatelessWidget { }); final Note note; + final Category? category; final bool isSelected; final Color? borderColor; final VoidCallback? onTap; @@ -28,6 +32,12 @@ class NoteCard extends StatelessWidget { Widget build(BuildContext context) { final AppPalette palette = Theme.of(context).extension()!; final String bodyText = noteBodyToPlainText(note.body).trim(); + final Color? categoryColor = category?.colorValue == null + ? null + : Color(category!.colorValue!); + final IconData? categoryIcon = category == null + ? null + : CategoryStyle.iconForCodePoint(category!.iconCodePoint); return Material( color: Colors.transparent, // 1. Fondo completamente transparente @@ -58,15 +68,34 @@ class NoteCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - note.title.isEmpty ? 'Sin título' : note.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: palette.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w700, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (categoryIcon != null) ...[ + Container( + width: 18, + height: 18, + child: Icon( + categoryIcon, + size: 18, + color: categoryColor ?? palette.textSecondary, + ), + ), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + note.title.isEmpty ? 'Sin título' : note.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ], ), const SizedBox(height: 6), Text( @@ -88,6 +117,11 @@ class NoteCard extends StatelessWidget { builder: (BuildContext buttonContext) { return PopupMenuButton( icon: Icon(Icons.more_vert, color: palette.textSecondary), + color: palette.surfaceElevated, + elevation: 10, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), onSelected: (String value) { switch (value) { case 'category':