From 817851cec3fe780dafbcd0332281c8fef5fe92fe Mon Sep 17 00:00:00 2001 From: Marcos Date: Sat, 11 Jul 2026 15:01:09 +0200 Subject: [PATCH] feat: Enhance SearchAppBar with debounce and customizable padding - Added debounce functionality for search input in SearchAppBar to improve performance. - Introduced a controller parameter to allow external control of the search input. - Made padding customizable via a new padding parameter. - Improved leading button handling with an option to show/hide it. feat: Implement Category Editor Dialog - Created a new CategoryEditorDialog widget for editing category details. - Allows users to set category name, color, and icon with a preview. - Supports creating new categories with a confirmation dialog. feat: Add Category Menu Button - Introduced CategoryMenuButton for selecting categories with an option to create new ones. - Displays a menu with existing categories and allows editing. - Supports dynamic background color based on active state. feat: Create Note List View - Added NoteListView widget for displaying a list of notes with reordering capability. - Integrates refresh functionality and displays last sync time. - Supports selection and deletion of notes with visual feedback. --- lib/screens/home_screen.dart | 753 +++++------------------- lib/widgets/category_editor_dialog.dart | 230 ++++++++ lib/widgets/category_menu_button.dart | 244 ++++++++ lib/widgets/note_list_view.dart | 103 ++++ lib/widgets/search_app_bar.dart | 85 ++- 5 files changed, 771 insertions(+), 644 deletions(-) create mode 100644 lib/widgets/category_editor_dialog.dart create mode 100644 lib/widgets/category_menu_button.dart create mode 100644 lib/widgets/note_list_view.dart diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0cec6bf..4e6e653 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:notas/data/note_body.dart'; import 'package:notas/data/note_repository.dart'; @@ -9,22 +8,13 @@ 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_editor_dialog.dart'; +import 'package:notas/widgets/category_menu_button.dart'; import 'package:notas/widgets/category_style.dart'; -import 'package:notas/widgets/note_card.dart'; +import 'package:notas/widgets/note_list_view.dart'; +import 'package:notas/widgets/search_app_bar.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, @@ -55,10 +45,8 @@ 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(); List _notes = []; List _categories = []; @@ -102,8 +90,7 @@ class _HomeScreenState extends State { try { final List notes = await widget.repository.loadNotes(); - final List categories = await widget.repository - .loadCategories(); + final List categories = await widget.repository.loadCategories(); final DateTime? lastSyncAt = await widget.repository.getLastSyncAt(); if (!mounted) { @@ -151,6 +138,16 @@ class _HomeScreenState extends State { } } + void _setSearchQuery(String value) { + if (!mounted) { + return; + } + + setState(() { + _searchQuery = value.trim(); + }); + } + List _visibleNotes() { Iterable notes = _notes; @@ -203,242 +200,26 @@ class _HomeScreenState extends State { return null; } - String _formatLastSyncAt() { - final DateTime? timestamp = _lastSyncAt; - if (timestamp == null) { - return 'Última sincronización: nunca'; - } - - return 'Última sincronización: ${DateFormat('dd/MM/yyyy HH:mm').format(timestamp)}'; - } - - Future<_CategoryDraft?> _promptCategoryDetails({ - required String title, - required String confirmLabel, - String? initialName, - int? initialColorValue, - int? initialIconCodePoint, - }) async { - final TextEditingController controller = TextEditingController( - 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 _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; - - 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.withValues(alpha: 0.0), - 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.withValues(alpha: 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), - ), - ], - ); - }, - ); - }, - ); - - if (result == null || result.name.trim().isEmpty) { - return null; + void _selectCategoryFilter(String? categoryId) { + setState(() { + _selectedCategoryId = categoryId; + if (_selectedNoteId != null && + !_visibleNotes().any((Note note) => note.id == _selectedNoteId)) { + _selectedNoteId = null; } - - return result; - } finally { - controller.dispose(); - } + }); } Future _saveCategory({Category? existingCategory}) async { - final _CategoryDraft? categoryDraft = await _promptCategoryDetails( + final CategoryDraft? categoryDraft = await showCategoryEditorDialog( + context: context, title: existingCategory == null ? 'Crear categoría' : 'Editar categoría', confirmLabel: existingCategory == null ? 'Crear' : 'Guardar', initialName: existingCategory?.name, initialColorValue: - existingCategory?.colorValue ?? - CategoryStyle.colorsOf(context).first.toARGB32(), + existingCategory?.colorValue ?? CategoryStyle.colorsOf(context).first.toARGB32(), initialIconCodePoint: - existingCategory?.iconCodePoint ?? - CategoryStyle.icons.first.codePoint, + existingCategory?.iconCodePoint ?? CategoryStyle.icons.first.codePoint, ); if (categoryDraft == null) { @@ -569,204 +350,24 @@ class _HomeScreenState extends State { } } - 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, - ); - } - - Future _showAnchoredCategoryMenu({ - required BuildContext anchorContext, - required List> items, - }) { - return showMenu( - context: anchorContext, - position: _menuRectFromContext(anchorContext), - items: items, - elevation: 10, - color: _paletteOf(anchorContext).surfaceElevated, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - ); - } - - 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.withValues(alpha: 0.05) : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: selected ? color.withValues(alpha: 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( + Future _changeNoteCategory(BuildContext anchorContext, Note note) async { + final String? selectedCategoryId = await showCategorySelectionMenu( anchorContext: anchorContext, - items: >[ - PopupMenuItem( - value: '', - 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: 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)); - }, - ); - }, - ), - ), - ], + categories: _categories, + selectedCategoryId: note.categoryId, + allowCreateCategory: true, + emptyLabel: 'Sin categoría', + createLabel: 'Crear categoría', + onEditCategory: (Category category) { + unawaited(_saveCategory(existingCategory: category)); + }, ); if (!mounted || selectedCategoryId == null) { return; } - setState(() { - _selectedCategoryId = selectedCategoryId.isEmpty - ? null - : selectedCategoryId; - if (_selectedNoteId != null && - !_visibleNotes().any((Note note) => note.id == _selectedNoteId)) { - _selectedNoteId = null; - } - }); - } - - Future _changeNoteCategory( - BuildContext anchorContext, - Note note, - ) async { - final String? selectedCategoryId = await _showAnchoredCategoryMenu( - anchorContext: anchorContext, - items: >[ - 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: 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(), - PopupMenuItem( - value: _createCategoryMenuValue, - child: _buildCategoryMenuItem( - context: anchorContext, - label: 'Crear categoría', - icon: Icons.add_circle_outline, - color: _paletteOf(anchorContext).textSecondary, - selected: false, - ), - ), - ], - ); - - if (!mounted || selectedCategoryId == null) { - return; - } - - if (selectedCategoryId == _createCategoryMenuValue) { + if (selectedCategoryId == kCreateCategoryMenuValue) { final Category? createdCategory = await _saveCategory(); if (createdCategory == null || !mounted) { return; @@ -868,9 +469,9 @@ class _HomeScreenState extends State { return; } - await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => editor)); + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => editor), + ); } Future _handleNoteTap(Note note, bool isDesktop) async { @@ -889,8 +490,7 @@ class _HomeScreenState extends State { } final Note movedNote = visibleNotes[oldIndex]; - final List remainingVisible = [...visibleNotes] - ..removeAt(oldIndex); + final List remainingVisible = [...visibleNotes]..removeAt(oldIndex); final int clampedNewIndex = newIndex.clamp(0, remainingVisible.length); int targetFullIndex; @@ -924,183 +524,6 @@ class _HomeScreenState extends State { } } - Widget _buildHeader(BuildContext context) { - final AppPalette palette = _paletteOf(context); - - return Container( - decoration: BoxDecoration( - color: palette.transparent, - border: Border(bottom: BorderSide(color: palette.border, width: 0.5)), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - Builder( - builder: (BuildContext buttonContext) { - return IconButton( - key: _filterButtonKey, - onPressed: () => _openCategoryFilter(buttonContext), - tooltip: 'Filtrar por categorías', - iconSize: 24, - style: IconButton.styleFrom( - backgroundColor: _selectedCategoryId == null - ? Colors.transparent - : palette.accent.withValues(alpha: 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, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(width: 8), - Expanded( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 640), - child: TextField( - controller: _searchController, - onChanged: (String value) { - setState(() { - _searchQuery = value.trim(); - }); - }, - decoration: InputDecoration( - hintText: 'Buscar notas...', - hintStyle: TextStyle(color: palette.textSecondary), - prefixIcon: Icon( - Icons.search, - color: palette.textSecondary, - ), - suffixIcon: _searchQuery.isEmpty - ? null - : IconButton( - onPressed: () { - _searchController.clear(); - setState(() { - _searchQuery = ''; - }); - }, - icon: Icon( - Icons.clear, - color: palette.textSecondary, - ), - ), - filled: true, - fillColor: palette.fill, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: palette.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: palette.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: palette.accent), - ), - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - ), - ), - ), - ), - ), - const SizedBox(width: 8), - IconButton( - onPressed: widget.onOpenSettings, - icon: Icon(Icons.settings_outlined, color: palette.textSecondary), - tooltip: 'Ajustes', - ), - ], - ), - ); - } - - Widget _buildNoteList(BuildContext context, {required bool isDesktop}) { - final AppPalette palette = _paletteOf(context); - final List visibleNotes = _visibleNotes(); - - if (_isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (visibleNotes.isEmpty) { - return Center( - child: Text( - 'No hay notas para mostrar', - style: TextStyle(color: palette.textSecondary), - ), - ); - } - - return RefreshIndicator( - onRefresh: () async { - await widget.onRequestSync(); - await _loadData(keepSelection: true); - }, - child: ReorderableListView.builder( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 14), - buildDefaultDragHandles: false, - itemCount: visibleNotes.length, - onReorderItem: _handleReorder, - footer: Padding( - 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: 6), - child: ReorderableDelayedDragStartListener( - index: index, - child: NoteCard( - note: note, - category: _categoryById(note.categoryId), - isSelected: note.id == _selectedNoteId, - showSelectionBorder: isDesktop, - onTap: () => _handleNoteTap(note, isDesktop), - onDelete: () => _deleteNoteAfterConfirmation(note), - onChangeCategory: (BuildContext buttonContext) => - _changeNoteCategory(buttonContext, note), - ), - ), - ); - }, - ), - ); - } - Widget _buildEmptyDetailPane(BuildContext context) { final AppPalette palette = _paletteOf(context); @@ -1138,8 +561,53 @@ class _HomeScreenState extends State { children: [ Column( children: [ - _buildHeader(context), - Expanded(child: _buildNoteList(context, isDesktop: true)), + SearchAppBar( + controller: _searchController, + leadingWidget: CategoryMenuButton( + categories: _categories, + selectedCategoryId: _selectedCategoryId, + onCategorySelected: _selectCategoryFilter, + onEditCategory: (Category category) { + unawaited(_saveCategory(existingCategory: category)); + }, + icon: Icons.filter_alt_outlined, + tooltip: 'Filtrar por categorías', + isActive: _selectedCategoryId != null, + ), + showLeadingButton: false, + trailingWidget: IconButton( + onPressed: widget.onOpenSettings, + icon: Icon( + Icons.settings_outlined, + color: palette.textSecondary, + ), + tooltip: 'Ajustes', + ), + onSearchChanged: _setSearchQuery, + searchMaxWidth: 640, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + Expanded( + child: NoteListView( + notes: _visibleNotes(), + isLoading: _isLoading, + selectedNoteId: _selectedNoteId, + showSelectionBorder: true, + categoryForNote: _categoryById, + lastSyncAt: _lastSyncAt, + onRefresh: () async { + await widget.onRequestSync(); + await _loadData(keepSelection: true); + }, + onReorder: _handleReorder, + onNoteTap: (Note note) => _handleNoteTap(note, true), + onDeleteNote: _deleteNoteAfterConfirmation, + onChangeCategory: _changeNoteCategory, + ), + ), ], ), Positioned( @@ -1187,12 +655,53 @@ class _HomeScreenState extends State { } Widget _buildMobileLayout(BuildContext context) { + final AppPalette palette = _paletteOf(context); + return Stack( children: [ Column( children: [ - _buildHeader(context), - Expanded(child: _buildNoteList(context, isDesktop: false)), + SearchAppBar( + controller: _searchController, + leadingWidget: CategoryMenuButton( + categories: _categories, + selectedCategoryId: _selectedCategoryId, + onCategorySelected: _selectCategoryFilter, + onEditCategory: (Category category) { + unawaited(_saveCategory(existingCategory: category)); + }, + icon: Icons.filter_alt_outlined, + tooltip: 'Filtrar por categorías', + isActive: _selectedCategoryId != null, + ), + showLeadingButton: false, + trailingWidget: IconButton( + onPressed: widget.onOpenSettings, + icon: Icon(Icons.settings_outlined, color: palette.textSecondary), + tooltip: 'Ajustes', + ), + onSearchChanged: _setSearchQuery, + searchMaxWidth: 640, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + Expanded( + child: NoteListView( + notes: _visibleNotes(), + isLoading: _isLoading, + selectedNoteId: _selectedNoteId, + showSelectionBorder: false, + categoryForNote: _categoryById, + lastSyncAt: _lastSyncAt, + onRefresh: () async { + await widget.onRequestSync(); + await _loadData(keepSelection: true); + }, + onReorder: _handleReorder, + onNoteTap: (Note note) => _handleNoteTap(note, false), + onDeleteNote: _deleteNoteAfterConfirmation, + onChangeCategory: _changeNoteCategory, + ), + ), ], ), Positioned( diff --git a/lib/widgets/category_editor_dialog.dart b/lib/widgets/category_editor_dialog.dart new file mode 100644 index 0000000..dabf1e4 --- /dev/null +++ b/lib/widgets/category_editor_dialog.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; + +import 'package:notas/theme/app_palette.dart'; +import 'package:notas/widgets/category_style.dart'; + +class CategoryDraft { + const CategoryDraft({ + required this.name, + required this.colorValue, + required this.iconCodePoint, + }); + + final String name; + final int colorValue; + final int iconCodePoint; +} + +Future showCategoryEditorDialog({ + required BuildContext context, + required String title, + required String confirmLabel, + String? initialName, + int? initialColorValue, + int? initialIconCodePoint, +}) async { + final TextEditingController controller = TextEditingController( + 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 CategoryDraft? result = await showDialog( + context: context, + builder: (BuildContext dialogContext) { + final AppPalette palette = Theme.of(dialogContext).extension() ?? + AppPalette.fromBrightness(Theme.of(dialogContext).brightness); + final List dialogColorOptions = CategoryStyle.colorsOf( + dialogContext, + ); + final List dialogIconOptions = CategoryStyle.icons; + + 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.withValues(alpha: 0.0), + 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.withValues(alpha: 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), + ), + ], + ); + }, + ); + }, + ); + + if (result == null || result.name.trim().isEmpty) { + return null; + } + + return result; + } finally { + controller.dispose(); + } +} \ No newline at end of file diff --git a/lib/widgets/category_menu_button.dart b/lib/widgets/category_menu_button.dart new file mode 100644 index 0000000..fc04fe2 --- /dev/null +++ b/lib/widgets/category_menu_button.dart @@ -0,0 +1,244 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:notas/models/category.dart'; +import 'package:notas/theme/app_palette.dart'; +import 'package:notas/widgets/category_style.dart'; + +const String kCreateCategoryMenuValue = '__create_category__'; + +class CategoryMenuButton extends StatelessWidget { + const CategoryMenuButton({ + super.key, + required this.categories, + required this.selectedCategoryId, + required this.onCategorySelected, + this.onCreateCategory, + this.onEditCategory, + this.icon = Icons.filter_alt_outlined, + this.tooltip = 'Filtrar por categorías', + this.isActive = false, + this.allowCreateCategory = false, + this.emptyLabel = 'Todas las categorías', + this.createLabel = 'Crear categoría', + this.backgroundColor, + }); + + final List categories; + final String? selectedCategoryId; + final ValueChanged onCategorySelected; + final FutureOr Function()? onCreateCategory; + final FutureOr Function(Category category)? onEditCategory; + final IconData icon; + final String tooltip; + final bool isActive; + final bool allowCreateCategory; + final String emptyLabel; + final String createLabel; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final AppPalette palette = Theme.of(context).extension() ?? + AppPalette.fromBrightness(Theme.of(context).brightness); + + return IconButton( + onPressed: () async { + final String? selectedValue = await showCategorySelectionMenu( + anchorContext: context, + categories: categories, + selectedCategoryId: selectedCategoryId, + allowCreateCategory: allowCreateCategory, + emptyLabel: emptyLabel, + createLabel: createLabel, + onEditCategory: onEditCategory, + ); + + if (selectedValue == null) { + return; + } + + if (selectedValue == kCreateCategoryMenuValue) { + await onCreateCategory?.call(); + return; + } + + onCategorySelected(selectedValue.isEmpty ? null : selectedValue); + }, + tooltip: tooltip, + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: backgroundColor ?? + (isActive ? palette.accent.withValues(alpha: 0.08) : Colors.transparent), + shape: const CircleBorder(), + ), + icon: Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon, color: palette.textSecondary), + if (isActive) + Positioned( + right: -1, + top: -1, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: palette.accent, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ); + } +} + +Future showCategorySelectionMenu({ + required BuildContext anchorContext, + required List categories, + required String? selectedCategoryId, + required bool allowCreateCategory, + required String emptyLabel, + required String createLabel, + FutureOr Function(Category category)? onEditCategory, +}) { + return showMenu( + context: anchorContext, + position: _menuRectFromContext(anchorContext), + elevation: 10, + color: _paletteOf(anchorContext).surfaceElevated, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + items: >[ + PopupMenuItem( + value: '', + child: _buildCategoryMenuItem( + context: anchorContext, + label: emptyLabel, + icon: allowCreateCategory + ? Icons.folder_outlined + : Icons.filter_alt_outlined, + color: _paletteOf(anchorContext).textSecondary, + selected: selectedCategoryId == null, + ), + ), + const PopupMenuDivider(), + for (final Category category in categories) + PopupMenuItem( + value: category.id, + 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: onEditCategory == null + ? null + : () { + Navigator.of(menuContext).pop(); + final FutureOr result = onEditCategory(category); + if (result is Future) { + unawaited(result); + } + }, + ); + }, + ), + ), + if (allowCreateCategory) ...[ + const PopupMenuDivider(), + PopupMenuItem( + value: kCreateCategoryMenuValue, + child: _buildCategoryMenuItem( + context: anchorContext, + label: createLabel, + icon: Icons.add_circle_outline, + color: _paletteOf(anchorContext).textSecondary, + selected: false, + ), + ), + ], + ], + ); +} + +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, + ); +} + +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.withValues(alpha: 0.05) : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? color.withValues(alpha: 0.42) : Colors.transparent, + width: 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, + ), + ], + ), + ); +} + +AppPalette _paletteOf(BuildContext context) { + return Theme.of(context).extension() ?? + AppPalette.fromBrightness(Theme.of(context).brightness); +} \ No newline at end of file diff --git a/lib/widgets/note_list_view.dart b/lib/widgets/note_list_view.dart new file mode 100644 index 0000000..0aaf07f --- /dev/null +++ b/lib/widgets/note_list_view.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:notas/models/category.dart'; +import 'package:notas/models/note.dart'; +import 'package:notas/theme/app_palette.dart'; +import 'package:notas/widgets/note_card.dart'; + +class NoteListView extends StatelessWidget { + const NoteListView({ + super.key, + required this.notes, + required this.isLoading, + required this.selectedNoteId, + required this.showSelectionBorder, + required this.categoryForNote, + required this.lastSyncAt, + required this.onRefresh, + required this.onReorder, + required this.onNoteTap, + required this.onDeleteNote, + required this.onChangeCategory, + }); + + final List notes; + final bool isLoading; + final String? selectedNoteId; + final bool showSelectionBorder; + final Category? Function(String? categoryId) categoryForNote; + final DateTime? lastSyncAt; + final Future Function() onRefresh; + final Future Function(int oldIndex, int newIndex) onReorder; + final void Function(Note note) onNoteTap; + final Future Function(Note note) onDeleteNote; + final Future Function(BuildContext buttonContext, Note note) + onChangeCategory; + + String _formatLastSyncAt() { + if (lastSyncAt == null) { + return 'Última sincronización: nunca'; + } + + return 'Última sincronización: ${DateFormat('dd/MM/yyyy HH:mm').format(lastSyncAt!)}'; + } + + @override + Widget build(BuildContext context) { + final AppPalette palette = Theme.of(context).extension() ?? + AppPalette.fromBrightness(Theme.of(context).brightness); + + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (notes.isEmpty) { + return Center( + child: Text( + 'No hay notas para mostrar', + style: TextStyle(color: palette.textSecondary), + ), + ); + } + + return RefreshIndicator( + onRefresh: onRefresh, + child: ReorderableListView.builder( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 14), + buildDefaultDragHandles: false, + itemCount: notes.length, + onReorderItem: onReorder, + footer: Padding( + 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 = notes[index]; + return Padding( + key: ValueKey(note.id), + padding: const EdgeInsets.only(bottom: 6), + child: ReorderableDelayedDragStartListener( + index: index, + child: NoteCard( + note: note, + category: categoryForNote(note.categoryId), + isSelected: note.id == selectedNoteId, + showSelectionBorder: showSelectionBorder, + onTap: () => onNoteTap(note), + onDelete: () => onDeleteNote(note), + onChangeCategory: (BuildContext buttonContext) => + onChangeCategory(buttonContext, note), + ), + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/search_app_bar.dart b/lib/widgets/search_app_bar.dart index e532770..6935a6a 100644 --- a/lib/widgets/search_app_bar.dart +++ b/lib/widgets/search_app_bar.dart @@ -1,29 +1,42 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; + import 'package:notas/theme/app_palette.dart'; class SearchAppBar extends StatefulWidget { const SearchAppBar({ super.key, + this.controller, this.onMenuPressed, this.onLeadingPressed, this.leadingIcon = Icons.menu, this.leadingTooltip = 'Menú', this.leadingWidget, + this.showLeadingButton = true, this.trailingWidget, this.onSearchChanged, this.searchHint = 'Buscar notas...', + this.searchMaxWidth = 600, + this.searchDebounceDuration = const Duration(milliseconds: 300), + this.padding = const EdgeInsets.only(left: 8, right: 20, top: 7, bottom: 7), this.showSearch = true, this.titleText, }); + final TextEditingController? controller; final VoidCallback? onMenuPressed; final VoidCallback? onLeadingPressed; final IconData leadingIcon; final String leadingTooltip; final Widget? leadingWidget; + final bool showLeadingButton; final Widget? trailingWidget; final ValueChanged? onSearchChanged; final String searchHint; + final double searchMaxWidth; + final Duration searchDebounceDuration; + final EdgeInsetsGeometry padding; final bool showSearch; final String? titleText; @@ -32,60 +45,87 @@ class SearchAppBar extends StatefulWidget { } class _SearchAppBarState extends State { - late TextEditingController _searchController; - void _onSearchChanged() { + late final TextEditingController _searchController; + late final bool _ownsController; + Timer? _debounceTimer; + + void _refreshState() { setState(() {}); } + void _handleSearchInput(String value) { + _refreshState(); + + if (widget.onSearchChanged == null) { + return; + } + + _debounceTimer?.cancel(); + _debounceTimer = Timer(widget.searchDebounceDuration, () { + if (!mounted) { + return; + } + + widget.onSearchChanged?.call(value.trim()); + }); + } + @override void initState() { super.initState(); - _searchController = TextEditingController()..addListener(_onSearchChanged); + _searchController = widget.controller ?? TextEditingController(); + _ownsController = widget.controller == null; + _searchController.addListener(_refreshState); } @override void dispose() { - _searchController.removeListener(_onSearchChanged); - _searchController.dispose(); + _debounceTimer?.cancel(); + _searchController.removeListener(_refreshState); + if (_ownsController) { + _searchController.dispose(); + } super.dispose(); } @override Widget build(BuildContext context) { final AppPalette palette = Theme.of(context).extension()!; + final Widget? leadingControl = widget.leadingWidget ?? + (widget.showLeadingButton + ? IconButton( + onPressed: widget.onLeadingPressed ?? widget.onMenuPressed, + icon: Icon( + widget.leadingIcon, + color: palette.textSecondary, + size: 20, + ), + tooltip: widget.leadingTooltip, + splashRadius: 18, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + ) + : null); return Container( decoration: BoxDecoration( color: palette.transparent, border: Border(bottom: BorderSide(color: palette.border, width: 0.5)), ), - padding: const EdgeInsets.only(left: 8, right: 20, top: 7, bottom: 7), + padding: widget.padding, child: Row( children: [ - IconButton( - onPressed: widget.onLeadingPressed ?? widget.onMenuPressed, - icon: Icon( - widget.leadingIcon, - color: palette.textSecondary, - size: 20, - ), - tooltip: widget.leadingTooltip, - splashRadius: 18, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), - ), - if (widget.leadingWidget != null) ...[ + if (leadingControl != null) ...[ + leadingControl, const SizedBox(width: 8), - Center(child: widget.leadingWidget!), ], - const SizedBox(width: 8), Expanded( child: widget.showSearch ? Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), + constraints: BoxConstraints(maxWidth: widget.searchMaxWidth), child: TextField( controller: _searchController, - onChanged: widget.onSearchChanged, + onChanged: _handleSearchInput, style: TextStyle( color: palette.textPrimary, fontSize: 13, @@ -104,6 +144,7 @@ class _SearchAppBarState extends State { size: 18, ), onPressed: () { + _debounceTimer?.cancel(); _searchController.clear(); widget.onSearchChanged?.call(''); },