refactor: Enhance category handling in note editor and card components

This commit is contained in:
2026-07-02 12:52:41 +02:00
parent 78dddd571a
commit f662e59547
3 changed files with 445 additions and 106 deletions
+346 -58
View File
@@ -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<HomeScreen> {
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,25 +212,49 @@ class _HomeScreenState extends State<HomeScreen> {
return 'Última sincronización: ${DateFormat('dd/MM/yyyy HH:mm').format(timestamp)}';
}
Future<String?> _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<Color> colorOptions = CategoryStyle.colorsOf(context);
final List<IconData> iconOptions = CategoryStyle.icons;
final int fallbackColorValue =
initialColorValue ?? colorOptions.first.toARGB32();
final int fallbackIconCodePoint =
initialIconCodePoint ?? iconOptions.first.codePoint;
try {
final String? result = await showDialog<String>(
final _CategoryDraft? result = await showDialog<_CategoryDraft>(
context: context,
builder: (BuildContext dialogContext) {
final AppPalette palette = _paletteOf(dialogContext);
final List<Color> dialogColorOptions = CategoryStyle.colorsOf(
dialogContext,
);
final List<IconData> 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: TextField(
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.done,
@@ -211,9 +262,133 @@ class _HomeScreenState extends State<HomeScreen> {
hintText: 'Nombre de la categoría',
),
onSubmitted: (String value) {
Navigator.of(dialogContext).pop(value.trim());
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(),
@@ -221,7 +396,18 @@ class _HomeScreenState extends State<HomeScreen> {
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop(controller.text.trim());
final String name = controller.text.trim();
if (name.isEmpty) {
return;
}
Navigator.of(dialogContext).pop(
_CategoryDraft(
name: name,
colorValue: selectedColorValue,
iconCodePoint: selectedIconCodePoint,
),
);
},
child: Text(confirmLabel),
),
@@ -229,36 +415,50 @@ class _HomeScreenState extends State<HomeScreen> {
);
},
);
},
);
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<Category?> _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,40 +599,95 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
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<void> _openCategoryFilter(BuildContext anchorContext) async {
final String? selectedCategoryId = await _showAnchoredCategoryMenu<String?>(
anchorContext: anchorContext,
items: <PopupMenuEntry<String?>>[
const PopupMenuItem<String?>(
PopupMenuItem<String?>(
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<String?>(
value: category.id,
child: Row(
children: [
Expanded(child: Text(category.name)),
const SizedBox(width: 8),
Builder(
child: 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: () {
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<HomeScreen> {
final String? selectedCategoryId = await _showAnchoredCategoryMenu<String?>(
anchorContext: anchorContext,
items: <PopupMenuEntry<String?>>[
const PopupMenuItem<String?>(value: '', child: Text('Sin categoría')),
PopupMenuItem<String?>(
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<String?>(
value: category.id,
child: Row(
children: [
Expanded(child: Text(category.name)),
const SizedBox(width: 8),
Builder(
child: 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: () {
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<String?>(
PopupMenuItem<String?>(
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<HomeScreen> {
return IconButton(
key: _filterButtonKey,
onPressed: () => _openCategoryFilter(buttonContext),
icon: Icon(
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,
),
tooltip: 'Filtrar por categorías',
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<HomeScreen> {
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<HomeScreen> {
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,
+20 -3
View File
@@ -231,10 +231,27 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
return editor;
}
return Scaffold(
backgroundColor: palette.cardBackground,
appBar: AppBar(title: const Text('Editar nota')),
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),
),
);
}
}
+35 -1
View File
@@ -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<AppPalette>()!;
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,7 +68,23 @@ class NoteCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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,
@@ -68,6 +94,9 @@ class NoteCard extends StatelessWidget {
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 6),
Text(
bodyText.isEmpty ? ' ' : bodyText,
@@ -88,6 +117,11 @@ class NoteCard extends StatelessWidget {
builder: (BuildContext buttonContext) {
return PopupMenuButton<String>(
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':