diff --git a/lib/models/note.dart b/lib/models/note.dart index 0627ed5..4113950 100644 --- a/lib/models/note.dart +++ b/lib/models/note.dart @@ -1,5 +1,7 @@ import 'package:uuid/uuid.dart'; +const Object _unsetCategoryId = Object(); + // Model: Note // - Representa una nota guardada en la app. class Note { @@ -36,12 +38,16 @@ class Note { DateTime? createdAt, DateTime? updatedAt, double? position, - String? categoryId, + Object? categoryId = _unsetCategoryId, int? serverVersion, bool? isDeleted, bool? isPermanentlyDeleted, bool? isDirty, }) { + final String? resolvedCategoryId = identical(categoryId, _unsetCategoryId) + ? this.categoryId + : categoryId as String?; + return Note( id: id ?? this.id, title: title ?? this.title, @@ -49,7 +55,7 @@ class Note { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, position: position ?? this.position, - categoryId: categoryId ?? this.categoryId, + categoryId: resolvedCategoryId, serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, isPermanentlyDeleted: isPermanentlyDeleted ?? this.isPermanentlyDeleted, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5b5fcfa..9826ea1 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -9,6 +9,7 @@ import 'package:notas/models/note.dart'; import 'package:notas/screens/note_editor_screen.dart'; import 'package:notas/models/category.dart'; import 'package:notas/widgets/menu_drawer.dart'; +import 'package:notas/widgets/category_style.dart'; import 'package:notas/widgets/note_card.dart'; import 'package:notas/widgets/search_app_bar.dart'; import 'package:notas/widgets/sync_status.dart'; @@ -148,6 +149,7 @@ class _HomeScreenState extends State { final dynamic result = await NoteEditorScreen.showDialog( context, categoryId: _showDeletedNotes ? null : _selectedCategoryId, + categories: _categories, ); if (result == null) { @@ -199,6 +201,7 @@ class _HomeScreenState extends State { final dynamic result = await NoteEditorScreen.showDialog( context, note: note, + categories: _categories, ); if (result == null) { @@ -786,28 +789,6 @@ class _CategoryDialogState extends State<_CategoryDialog> { IconData? _selectedIcon; int _selectedSection = 0; - final List _palette = [ - Colors.amber, - Colors.blue, - Colors.green, - Colors.purple, - Colors.red, - Colors.teal, - Colors.orange, - Colors.grey, - ]; - - final List _icons = [ - Icons.folder, - Icons.work, - Icons.star, - Icons.home, - Icons.school, - Icons.book, - Icons.music_note, - Icons.lightbulb, - ]; - @override void initState() { super.initState(); @@ -817,9 +798,9 @@ class _CategoryDialogState extends State<_CategoryDialog> { ? Color(widget.category!.colorValue!) : null; if (widget.category != null && widget.category!.iconCodePoint != null) { - _selectedIcon = _icons.firstWhere( + _selectedIcon = CategoryStyle.icons.firstWhere( (IconData icon) => icon.codePoint == widget.category!.iconCodePoint, - orElse: () => _icons.first, + orElse: () => CategoryStyle.icons.first, ); } } @@ -977,7 +958,7 @@ class _CategoryDialogState extends State<_CategoryDialog> { key: const ValueKey('colors'), spacing: 10, runSpacing: 10, - children: _palette.map((Color color) { + children: CategoryStyle.colors.map((Color color) { final bool isSelected = _selectedColor?.value == color.value; return GestureDetector( @@ -1009,7 +990,7 @@ class _CategoryDialogState extends State<_CategoryDialog> { key: const ValueKey('icons'), spacing: 10, runSpacing: 10, - children: _icons.map((IconData icon) { + children: CategoryStyle.icons.map((IconData icon) { final bool isSelected = _selectedIcon == icon; return GestureDetector( onTap: () => setState(() { diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index ceb834b..9869a5c 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -4,8 +4,10 @@ import 'dart:math' as math; 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/platform/app_platform.dart'; +import 'package:notas/widgets/category_style.dart'; // NoteEditorScreen: unified UI for creating and editing notes. // - Use `NoteEditorScreen.showDialog(context, note: existing)` to edit. @@ -18,11 +20,13 @@ class NoteEditorScreen extends StatefulWidget { super.key, required this.note, this.categoryId, + this.categories = const [], this.onComplete, }); final Note? note; final String? categoryId; + final List categories; final ValueChanged? onComplete; @override @@ -32,6 +36,7 @@ class NoteEditorScreen extends StatefulWidget { BuildContext context, { Note? note, String? categoryId, + List categories = const [], }) { return showGeneralDialog( context: context, @@ -39,7 +44,11 @@ class NoteEditorScreen extends StatefulWidget { barrierColor: Colors.transparent, transitionDuration: const Duration(milliseconds: 200), pageBuilder: (context, animation, secondaryAnimation) { - return NoteEditorScreen(note: note, categoryId: categoryId); + return NoteEditorScreen( + note: note, + categoryId: categoryId, + categories: categories, + ); }, transitionBuilder: (context, animation, secondaryAnimation, child) { return ScaleTransition(scale: animation, child: child); @@ -51,12 +60,14 @@ class NoteEditorScreen extends StatefulWidget { BuildContext context, { Note? note, String? categoryId, + List categories = const [], }) { if (isAndroid || isIOS) { return _showGeneralEditorDialog( context, note: note, categoryId: categoryId, + categories: categories, ); } @@ -66,6 +77,7 @@ class NoteEditorScreen extends StatefulWidget { context, note: note, categoryId: categoryId, + categories: categories, ); } @@ -77,6 +89,7 @@ class NoteEditorScreen extends StatefulWidget { return NoteEditorScreen( note: note, categoryId: categoryId, + categories: categories, onComplete: (dynamic result) { if (!completer.isCompleted) { completer.complete(result); @@ -99,6 +112,10 @@ class _NoteEditorScreenState extends State { late TextEditingController _bodyController; late Note _currentNote; late bool _isNewNote; + String? _selectedCategoryId; + final GlobalKey _categorySelectorKey = GlobalKey(); + OverlayEntry? _categoryMenuEntry; + bool _didComplete = false; bool get _isMobileLayout => isAndroid || isIOS; @@ -121,18 +138,28 @@ class _NoteEditorScreenState extends State { _currentNote = widget.note!; } + _selectedCategoryId = _currentNote.categoryId ?? widget.categoryId; + _titleController = TextEditingController(text: _currentNote.title); _bodyController = TextEditingController(text: _currentNote.body); } @override void dispose() { + _closeCategoryMenu(); _titleController.dispose(); _bodyController.dispose(); super.dispose(); } void _complete(dynamic result) { + if (_didComplete) { + return; + } + + _didComplete = true; + _closeCategoryMenu(); + final ValueChanged? callback = widget.onComplete; if (callback != null) { @@ -150,8 +177,9 @@ class _NoteEditorScreenState extends State { void _saveNote() { final String title = _titleController.text.trim(); final String body = _bodyController.text.trim(); + final bool categoryChanged = _selectedCategoryId != _currentNote.categoryId; - if (title.isEmpty && body.isEmpty) { + if (title.isEmpty && body.isEmpty && !categoryChanged) { _complete(null); return; } @@ -159,6 +187,7 @@ class _NoteEditorScreenState extends State { final Note updatedNote = _currentNote.copyWith( title: title.isEmpty ? 'Sin título' : title, body: body, + categoryId: _selectedCategoryId, updatedAt: DateTime.now(), isDirty: true, ); @@ -278,6 +307,217 @@ class _NoteEditorScreenState extends State { return completer.future; } + Category? _categoryById(String? id) { + for (final Category category in widget.categories) { + if (category.id == id) { + return category; + } + } + + return null; + } + + Color _categoryBackgroundColor(Category? category) { + if (category?.colorValue == null) { + return Colors.white.withValues(alpha: 0.08); + } + + return Color(category!.colorValue!); + } + + Color _categoryForegroundColor(Category? category) { + if (category == null || category.colorValue == null) { + return Colors.white; + } + + final Color background = Color(category.colorValue!); + return background.computeLuminance() > 0.55 ? Colors.black87 : Colors.white; + } + + Widget _buildCategorySelectorBox({Category? category}) { + final String label = category?.name ?? 'Sin categoría'; + final IconData icon = CategoryStyle.iconForCodePoint(category?.iconCodePoint); + final Color backgroundColor = _categoryBackgroundColor(category); + final Color foregroundColor = _categoryForegroundColor(category); + + return Container( + key: _categorySelectorKey, + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: category?.colorValue != null + ? backgroundColor.withValues(alpha: 0.85) + : Colors.white24, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: foregroundColor, size: 15), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foregroundColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 6), + Icon( + Icons.arrow_drop_down, + color: foregroundColor.withValues(alpha: 0.9), + size: 16, + ), + ], + ), + ); + } + + void _closeCategoryMenu() { + final OverlayEntry? entry = _categoryMenuEntry; + if (entry != null && entry.mounted) { + entry.remove(); + } + _categoryMenuEntry = null; + } + + void _toggleCategoryMenu() { + if (_categoryMenuEntry != null) { + _closeCategoryMenu(); + return; + } + + final OverlayState? overlayState = Overlay.of(context, rootOverlay: true); + if (overlayState == null) { + return; + } + + _categoryMenuEntry = OverlayEntry( + builder: (BuildContext overlayContext) { + final Size screenSize = MediaQuery.of(overlayContext).size; + final double menuWidth = math.min(screenSize.width - 32, 320); + final double menuHeight = math.min(screenSize.height - 32, 360); + + return Material( + color: Colors.transparent, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _closeCategoryMenu, + child: const SizedBox.expand(), + ), + ), + Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: menuWidth, + maxHeight: menuHeight, + ), + child: Material( + elevation: 10, + color: const Color(0xFF303134), + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + shrinkWrap: true, + children: [ + _buildCategoryMenuItem( + category: null, + label: 'Sin categoría', + isSelected: _selectedCategoryId == null, + onTap: () { + setState(() { + _selectedCategoryId = null; + }); + _closeCategoryMenu(); + }, + ), + for (final Category category in widget.categories) + _buildCategoryMenuItem( + category: category, + label: category.name, + isSelected: _selectedCategoryId == category.id, + onTap: () { + setState(() { + _selectedCategoryId = category.id; + }); + _closeCategoryMenu(); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + + overlayState.insert(_categoryMenuEntry!); + } + + Widget _buildCategoryMenuItem({ + required Category? category, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + final Color backgroundColor = _categoryBackgroundColor(category); + final Color foregroundColor = _categoryForegroundColor(category); + final IconData icon = CategoryStyle.iconForCodePoint(category?.iconCodePoint); + + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + color: isSelected ? Colors.white.withValues(alpha: 0.08) : null, + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: category?.colorValue != null + ? backgroundColor.withValues(alpha: 0.85) + : Colors.white24, + ), + ), + child: Icon(icon, size: 16, color: foregroundColor), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foregroundColor, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + if (isSelected) + Icon(Icons.check, color: foregroundColor, size: 18), + ], + ), + ), + ); + } + Future _deleteNote() async { final bool confirmed = await _showDeleteConfirmation(); if (!mounted || !confirmed) { @@ -343,6 +583,22 @@ class _NoteEditorScreenState extends State { ], ), ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: SizedBox( + width: 150, + child: KeyedSubtree( + key: const ValueKey('category_selector'), + child: InkWell( + onTap: _toggleCategoryMenu, + borderRadius: BorderRadius.circular(12), + child: _buildCategorySelectorBox( + category: _categoryById(_selectedCategoryId), + ), + ), + ), + ), + ), ], ), ), diff --git a/lib/widgets/category_style.dart b/lib/widgets/category_style.dart new file mode 100644 index 0000000..6574381 --- /dev/null +++ b/lib/widgets/category_style.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class CategoryStyle { + CategoryStyle._(); + + static const List colors = [ + Colors.amber, + Colors.blue, + Colors.green, + Colors.purple, + Colors.red, + Colors.teal, + Colors.orange, + Colors.grey, + ]; + + static const List icons = [ + Icons.folder, + Icons.work, + Icons.star, + Icons.home, + Icons.school, + Icons.book, + Icons.music_note, + Icons.lightbulb, + ]; + + static IconData iconForCodePoint(int? codePoint) { + if (codePoint == null) { + return Icons.folder_outlined; + } + + for (final IconData icon in icons) { + if (icon.codePoint == codePoint) { + return icon; + } + } + + return Icons.folder_outlined; + } +} \ No newline at end of file diff --git a/lib/widgets/menu_drawer.dart b/lib/widgets/menu_drawer.dart index 9562a32..397860f 100644 --- a/lib/widgets/menu_drawer.dart +++ b/lib/widgets/menu_drawer.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:notas/models/category.dart'; +import 'package:notas/widgets/category_style.dart'; class MenuDrawer extends StatelessWidget { const MenuDrawer({ @@ -45,9 +46,10 @@ class MenuDrawer extends StatelessWidget { child: Column( children: categories.map((category) { final categoryId = 'category_${category.id}'; - final IconData categoryIcon = _iconForCodePoint( - category.iconCodePoint, - ); + final IconData categoryIcon = + CategoryStyle.iconForCodePoint( + category.iconCodePoint, + ); return _MenuItemTile( icon: categoryIcon, @@ -105,31 +107,6 @@ class MenuDrawer extends StatelessWidget { } } -IconData _iconForCodePoint(int? codePoint) { - if (codePoint == null) { - return Icons.folder_outlined; - } - - const List icons = [ - Icons.folder, - Icons.work, - Icons.star, - Icons.home, - Icons.school, - Icons.book, - Icons.music_note, - Icons.lightbulb, - ]; - - for (final IconData icon in icons) { - if (icon.codePoint == codePoint) { - return icon; - } - } - - return Icons.folder_outlined; -} - class _MenuItemTile extends StatefulWidget { const _MenuItemTile({ required this.icon, diff --git a/test/note_editor_screen_test.dart b/test/note_editor_screen_test.dart new file mode 100644 index 0000000..8e0279d --- /dev/null +++ b/test/note_editor_screen_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:notas/models/category.dart'; +import 'package:notas/models/note.dart'; +import 'package:notas/screens/note_editor_screen.dart'; + +void main() { + testWidgets('saves a note when only the category changes', ( + WidgetTester tester, + ) async { + Note? savedNote; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorScreen( + note: null, + categoryId: null, + categories: [ + Category( + id: 'work', + name: 'Trabajo', + updatedAt: DateTime(2026, 5, 21), + ), + ], + onComplete: (dynamic result) { + savedNote = result as Note?; + }, + ), + ), + ), + ); + + expect(find.text('Sin categoría'), findsWidgets); + + await tester.tap(find.byKey(const ValueKey('category_selector'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Trabajo').last); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Guardar')); + await tester.pumpAndSettle(); + + expect(savedNote, isNotNull); + expect(savedNote!.categoryId, 'work'); + expect(savedNote!.title, 'Sin título'); + }); + + testWidgets('only completes once when save is tapped twice', ( + WidgetTester tester, + ) async { + int completionCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorScreen( + note: null, + categoryId: null, + categories: [], + onComplete: (dynamic result) { + if (result is Note) { + completionCount += 1; + } + }, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField).first, 'Nota de prueba'); + + await tester.tap(find.text('Guardar')); + await tester.tap(find.text('Guardar')); + await tester.pumpAndSettle(); + + expect(completionCount, 1); + }); +} \ No newline at end of file diff --git a/test/note_test.dart b/test/note_test.dart new file mode 100644 index 0000000..2d1f7f2 --- /dev/null +++ b/test/note_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:notas/models/note.dart'; + +void main() { + test('copyWith can clear categoryId explicitly', () { + final Note note = Note( + title: 'A', + body: 'B', + createdAt: DateTime(2026, 5, 21), + updatedAt: DateTime(2026, 5, 21), + position: 0, + categoryId: 'cat-1', + ); + + final Note cleared = note.copyWith(categoryId: null); + + expect(cleared.categoryId, isNull); + expect(cleared.id, note.id); + }); +} \ No newline at end of file