diff --git a/lib/data/app_database.dart b/lib/data/app_database.dart index ea7a6b8..ac0ecaf 100644 --- a/lib/data/app_database.dart +++ b/lib/data/app_database.dart @@ -15,6 +15,8 @@ class Categories extends Table { integer().named('server_version').withDefault(const Constant(0))(); BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))(); + IntColumn get colorValue => integer().nullable().named('color_value')(); + IntColumn get iconCodePoint => integer().nullable().named('icon_code_point')(); BoolColumn get isDirty => boolean().named('is_dirty').withDefault(const Constant(true))(); DateTimeColumn get updatedAt => dateTime().named('updated_at')(); @@ -46,7 +48,7 @@ class Notes extends Table { @DriftDatabase(tables: [Notes, Categories]) class AppDatabase extends _$AppDatabase { @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -61,6 +63,12 @@ class AppDatabase extends _$AppDatabase { await customStatement('UPDATE notes SET is_dirty = 0'); await customStatement('UPDATE categories SET is_dirty = 0'); } + if (from < 3) { + await migrator.addColumn(categories, categories.colorValue); + await migrator.addColumn(categories, categories.iconCodePoint); + await customStatement('UPDATE categories SET color_value = NULL'); + await customStatement('UPDATE categories SET icon_code_point = NULL'); + } }, ); diff --git a/lib/data/app_database.g.dart b/lib/data/app_database.g.dart index 205a49f..3e990d5 100644 --- a/lib/data/app_database.g.dart +++ b/lib/data/app_database.g.dart @@ -682,6 +682,28 @@ class $CategoriesTable extends Categories ), defaultValue: const Constant(true), ); + static const VerificationMeta _colorValueMeta = const VerificationMeta( + 'colorValue', + ); + @override + late final GeneratedColumn colorValue = GeneratedColumn( + 'color_value', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _iconCodePointMeta = const VerificationMeta( + 'iconCodePoint', + ); + @override + late final GeneratedColumn iconCodePoint = GeneratedColumn( + 'icon_code_point', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); static const VerificationMeta _updatedAtMeta = const VerificationMeta( 'updatedAt', ); @@ -700,6 +722,8 @@ class $CategoriesTable extends Categories serverVersion, isDeleted, isDirty, + colorValue, + iconCodePoint, updatedAt, ]; @override @@ -751,6 +775,21 @@ class $CategoriesTable extends Categories isDirty.isAcceptableOrUnknown(data['is_dirty']!, _isDirtyMeta), ); } + if (data.containsKey('color_value')) { + context.handle( + _colorValueMeta, + colorValue.isAcceptableOrUnknown(data['color_value']!, _colorValueMeta), + ); + } + if (data.containsKey('icon_code_point')) { + context.handle( + _iconCodePointMeta, + iconCodePoint.isAcceptableOrUnknown( + data['icon_code_point']!, + _iconCodePointMeta, + ), + ); + } if (data.containsKey('updated_at')) { context.handle( _updatedAtMeta, @@ -788,6 +827,14 @@ class $CategoriesTable extends Categories DriftSqlType.bool, data['${effectivePrefix}is_dirty'], )!, + colorValue: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color_value'], + ), + iconCodePoint: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}icon_code_point'], + ), updatedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}updated_at'], @@ -807,6 +854,8 @@ class DbCategory extends DataClass implements Insertable { final int serverVersion; final bool isDeleted; final bool isDirty; + final int? colorValue; + final int? iconCodePoint; final DateTime updatedAt; const DbCategory({ required this.id, @@ -814,6 +863,8 @@ class DbCategory extends DataClass implements Insertable { required this.serverVersion, required this.isDeleted, required this.isDirty, + this.colorValue, + this.iconCodePoint, required this.updatedAt, }); @override @@ -824,6 +875,12 @@ class DbCategory extends DataClass implements Insertable { map['server_version'] = Variable(serverVersion); map['is_deleted'] = Variable(isDeleted); map['is_dirty'] = Variable(isDirty); + if (!nullToAbsent || colorValue != null) { + map['color_value'] = Variable(colorValue); + } + if (!nullToAbsent || iconCodePoint != null) { + map['icon_code_point'] = Variable(iconCodePoint); + } map['updated_at'] = Variable(updatedAt); return map; } @@ -835,6 +892,12 @@ class DbCategory extends DataClass implements Insertable { serverVersion: Value(serverVersion), isDeleted: Value(isDeleted), isDirty: Value(isDirty), + colorValue: colorValue == null && nullToAbsent + ? const Value.absent() + : Value(colorValue), + iconCodePoint: iconCodePoint == null && nullToAbsent + ? const Value.absent() + : Value(iconCodePoint), updatedAt: Value(updatedAt), ); } @@ -850,6 +913,8 @@ class DbCategory extends DataClass implements Insertable { serverVersion: serializer.fromJson(json['serverVersion']), isDeleted: serializer.fromJson(json['isDeleted']), isDirty: serializer.fromJson(json['isDirty']), + colorValue: serializer.fromJson(json['colorValue']), + iconCodePoint: serializer.fromJson(json['iconCodePoint']), updatedAt: serializer.fromJson(json['updatedAt']), ); } @@ -862,6 +927,8 @@ class DbCategory extends DataClass implements Insertable { 'serverVersion': serializer.toJson(serverVersion), 'isDeleted': serializer.toJson(isDeleted), 'isDirty': serializer.toJson(isDirty), + 'colorValue': serializer.toJson(colorValue), + 'iconCodePoint': serializer.toJson(iconCodePoint), 'updatedAt': serializer.toJson(updatedAt), }; } @@ -872,6 +939,8 @@ class DbCategory extends DataClass implements Insertable { int? serverVersion, bool? isDeleted, bool? isDirty, + int? colorValue, + int? iconCodePoint, DateTime? updatedAt, }) => DbCategory( id: id ?? this.id, @@ -879,6 +948,8 @@ class DbCategory extends DataClass implements Insertable { serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, isDirty: isDirty ?? this.isDirty, + colorValue: colorValue ?? this.colorValue, + iconCodePoint: iconCodePoint ?? this.iconCodePoint, updatedAt: updatedAt ?? this.updatedAt, ); DbCategory copyWithCompanion(CategoriesCompanion data) { @@ -892,6 +963,12 @@ class DbCategory extends DataClass implements Insertable { : this.serverVersion, isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, isDirty: data.isDirty.present ? data.isDirty.value : this.isDirty, + colorValue: data.colorValue.present + ? data.colorValue.value + : this.colorValue, + iconCodePoint: data.iconCodePoint.present + ? data.iconCodePoint.value + : this.iconCodePoint, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, ); } @@ -938,12 +1015,16 @@ class CategoriesCompanion extends UpdateCompanion { final Value isDirty; final Value updatedAt; final Value rowid; + final Value colorValue; + final Value iconCodePoint; const CategoriesCompanion({ this.id = const Value.absent(), this.encryptedName = const Value.absent(), this.serverVersion = const Value.absent(), this.isDeleted = const Value.absent(), this.isDirty = const Value.absent(), + this.colorValue = const Value.absent(), + this.iconCodePoint = const Value.absent(), this.updatedAt = const Value.absent(), this.rowid = const Value.absent(), }); @@ -953,6 +1034,8 @@ class CategoriesCompanion extends UpdateCompanion { this.serverVersion = const Value.absent(), this.isDeleted = const Value.absent(), this.isDirty = const Value.absent(), + this.colorValue = const Value.absent(), + this.iconCodePoint = const Value.absent(), required DateTime updatedAt, this.rowid = const Value.absent(), }) : id = Value(id), @@ -964,6 +1047,8 @@ class CategoriesCompanion extends UpdateCompanion { Expression? serverVersion, Expression? isDeleted, Expression? isDirty, + Expression? colorValue, + Expression? iconCodePoint, Expression? updatedAt, Expression? rowid, }) { @@ -973,6 +1058,8 @@ class CategoriesCompanion extends UpdateCompanion { if (serverVersion != null) 'server_version': serverVersion, if (isDeleted != null) 'is_deleted': isDeleted, if (isDirty != null) 'is_dirty': isDirty, + if (colorValue != null) 'color_value': colorValue, + if (iconCodePoint != null) 'icon_code_point': iconCodePoint, if (updatedAt != null) 'updated_at': updatedAt, if (rowid != null) 'rowid': rowid, }); @@ -984,6 +1071,8 @@ class CategoriesCompanion extends UpdateCompanion { Value? serverVersion, Value? isDeleted, Value? isDirty, + Value? colorValue, + Value? iconCodePoint, Value? updatedAt, Value? rowid, }) { @@ -993,6 +1082,8 @@ class CategoriesCompanion extends UpdateCompanion { serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, isDirty: isDirty ?? this.isDirty, + colorValue: colorValue ?? this.colorValue, + iconCodePoint: iconCodePoint ?? this.iconCodePoint, updatedAt: updatedAt ?? this.updatedAt, rowid: rowid ?? this.rowid, ); @@ -1016,6 +1107,12 @@ class CategoriesCompanion extends UpdateCompanion { if (isDirty.present) { map['is_dirty'] = Variable(isDirty.value); } + if (colorValue.present) { + map['color_value'] = Variable(colorValue.value); + } + if (iconCodePoint.present) { + map['icon_code_point'] = Variable(iconCodePoint.value); + } if (updatedAt.present) { map['updated_at'] = Variable(updatedAt.value); } diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index cb4e378..a3cf88d 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -12,6 +12,7 @@ import 'package:notas/models/category.dart'; import 'package:notas/data/note_encryption.dart'; import 'package:notas/widgets/sync_status.dart'; +import 'package:flutter/foundation.dart' show debugPrint; class NoteRepository { NoteRepository({ @@ -34,6 +35,56 @@ class NoteRepository { return _loadDeletedNotesFromDatabase(); } + Future> loadCategories() async { + final List dbCategories = await _database.getAllCategories(); + final List categories = []; + for (final DbCategory row in dbCategories) { + try { + final String decryptedName = await NoteEncryption.decryptNote( + row.encryptedName, + _masterKey, + ); + categories.add( + Category( + id: row.id, + name: decryptedName, + serverVersion: row.serverVersion, + isDeleted: row.isDeleted, + updatedAt: row.updatedAt, + isDirty: row.isDirty, + colorValue: row.colorValue, + iconCodePoint: row.iconCodePoint, + ), + ); + } catch (e) { + debugPrint('Error al desencriptar categoría: $e'); + } + } + return categories; + } + + Future createCategory(Category category) async { + debugPrint('createCategory called with: ${category.name}'); + final String encryptedName = await NoteEncryption.encryptNote( + category.name, + _masterKey, + ); + debugPrint('Category name encrypted'); + await _database.upsertCategory( + CategoriesCompanion.insert( + id: category.id, + encryptedName: encryptedName, + updatedAt: category.updatedAt, + serverVersion: const Value(0), + isDeleted: const Value(false), + isDirty: const Value(true), + colorValue: Value(category.colorValue), + iconCodePoint: Value(category.iconCodePoint), + ), + ); + debugPrint('Category inserted to database'); + } + Future createNote(Note note) async { await _database.insertNoteAtTop( NotesCompanion.insert( @@ -60,7 +111,7 @@ class NoteRepository { final DbNote row = existingNote ?? - (throw ArgumentError('Note not found for id ${note.id}.')); + (throw ArgumentError('Note not found for id ${note.id}.')); await _database.updateNoteRow( DbNote( @@ -92,7 +143,7 @@ class NoteRepository { final DbNote row = existingNote ?? - (throw ArgumentError('Note not found for id ${note.id}.')); + (throw ArgumentError('Note not found for id ${note.id}.')); if (row.isDeleted || note.isDeleted || note.isPermanentlyDeleted) { await _database.permanentlyDeleteNote(row.id); @@ -111,7 +162,7 @@ class NoteRepository { final DbNote row = existingNote ?? - (throw ArgumentError('Note not found for id ${note.id}.')); + (throw ArgumentError('Note not found for id ${note.id}.')); await _database.moveNote( id: row.id, @@ -212,10 +263,7 @@ class NoteRepository { details.add('StackTrace: ${stackTrace.toString()}'); } - return { - 'error': true, - 'message': details.join('\n\n'), - }; + return {'error': true, 'message': details.join('\n\n')}; } final SyncResponse response = syncResult['data'] as SyncResponse; @@ -249,10 +297,7 @@ class NoteRepository { 'categoriesCount': response.changes.categories.length, }; } catch (e, st) { - return { - 'error': true, - 'message': '$e\n\nStackTrace: $st', - }; + return {'error': true, 'message': '$e\n\nStackTrace: $st'}; } } @@ -263,21 +308,18 @@ class NoteRepository { // Apply categories from server for (final SyncCategoryResponse catResponse in response.changes.categories) { - final String categoryName = - catResponse.isDeleted || catResponse.encryptedName.isEmpty - ? '' - : await NoteEncryption.decryptNote( - catResponse.encryptedName, - _masterKey, - ); - + // Store the encrypted blob received from the server directly in the DB. + // Decryption is only performed when loading categories for display. + final String encryptedBlob = catResponse.encryptedName; await _database.upsertCategory( CategoriesCompanion( id: Value(catResponse.id), - encryptedName: Value(categoryName), + encryptedName: Value(encryptedBlob), serverVersion: Value(catResponse.serverVersion), isDeleted: Value(catResponse.isDeleted), + colorValue: Value(catResponse.colorValue), + iconCodePoint: Value(catResponse.iconCodePoint), updatedAt: Value(catResponse.updatedAt), isDirty: const Value(false), ), @@ -439,9 +481,10 @@ Future> _encryptCategories( final List payloads = []; for (final DbCategory row in categories) { - final String encryptedName = row.encryptedName.isEmpty - ? '' - : await NoteEncryption.encryptNote(row.encryptedName, masterKey); + // The DB already stores the encrypted name blob in `encryptedName`. + // Use it directly when building the sync payload and preserve + // color/icon values from the DB row so they are sent to the server. + final String encryptedName = row.encryptedName; payloads.add( SyncCategoryPayload.fromCategory( @@ -452,6 +495,8 @@ Future> _encryptCategories( isDeleted: row.isDeleted, updatedAt: row.updatedAt, isDirty: row.isDirty, + colorValue: row.colorValue, + iconCodePoint: row.iconCodePoint, ), encryptedName: encryptedName, ), diff --git a/lib/data/sync_models.dart b/lib/data/sync_models.dart index 23e376a..fd10126 100644 --- a/lib/data/sync_models.dart +++ b/lib/data/sync_models.dart @@ -95,6 +95,8 @@ class SyncCategoryPayload { required this.encryptedName, required this.serverVersion, this.isDeleted = false, + this.colorValue, + this.iconCodePoint, required this.updatedAt, }); @@ -102,6 +104,8 @@ class SyncCategoryPayload { final String encryptedName; final int serverVersion; final bool isDeleted; + final int? colorValue; + final int? iconCodePoint; final DateTime updatedAt; factory SyncCategoryPayload.fromCategory( @@ -113,6 +117,8 @@ class SyncCategoryPayload { encryptedName: encryptedName, serverVersion: category.serverVersion, isDeleted: category.isDeleted, + colorValue: category.colorValue, + iconCodePoint: category.iconCodePoint, updatedAt: category.updatedAt, ); } @@ -123,6 +129,8 @@ class SyncCategoryPayload { 'encrypted_name': encryptedName, 'serverVersion': serverVersion, 'isDeleted': isDeleted, + if (colorValue != null) 'colorValue': colorValue!.toSigned(32), + if (iconCodePoint != null) 'iconCodePoint': iconCodePoint, 'updatedAt': updatedAt.toIso8601String(), }; } @@ -213,12 +221,16 @@ class SyncCategoryResponse { required this.encryptedName, required this.serverVersion, this.isDeleted = false, + this.colorValue, + this.iconCodePoint, required this.updatedAt, }); final String id; final String encryptedName; final int serverVersion; final bool isDeleted; + final int? colorValue; + final int? iconCodePoint; final DateTime updatedAt; factory SyncCategoryResponse.fromJson(Map json) { @@ -227,6 +239,12 @@ class SyncCategoryResponse { encryptedName: _readStringValue(json['encrypted_name']), serverVersion: _readIntValue(json['serverVersion']), isDeleted: json['isDeleted'] as bool? ?? false, + colorValue: json['colorValue'] == null + ? null + : _readIntValue(json['colorValue']), + iconCodePoint: json['iconCodePoint'] == null + ? null + : _readIntValue(json['iconCodePoint']), updatedAt: DateTime.parse(json['updatedAt'] as String), ); } @@ -237,6 +255,8 @@ class SyncCategoryResponse { name: name, serverVersion: serverVersion, isDeleted: isDeleted, + colorValue: colorValue, + iconCodePoint: iconCodePoint, updatedAt: updatedAt, ); } diff --git a/lib/models/category.dart b/lib/models/category.dart index 18e7cf8..8de0afa 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -8,6 +8,8 @@ class Category { this.isDeleted = false, required this.updatedAt, this.isDirty = true, + this.colorValue, + this.iconCodePoint, }) : id = id ?? Uuid().v4(); final String id; @@ -16,6 +18,8 @@ class Category { final bool isDeleted; final DateTime updatedAt; final bool isDirty; + final int? colorValue; + final int? iconCodePoint; Category copyWith({ String? id, @@ -24,6 +28,8 @@ class Category { bool? isDeleted, DateTime? updatedAt, bool? isDirty, + int? colorValue, + int? iconCodePoint, }) { return Category( id: id ?? this.id, @@ -32,6 +38,8 @@ class Category { isDeleted: isDeleted ?? this.isDeleted, updatedAt: updatedAt ?? this.updatedAt, isDirty: isDirty ?? this.isDirty, + colorValue: colorValue ?? this.colorValue, + iconCodePoint: iconCodePoint ?? this.iconCodePoint, ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6b31330..6930c54 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:notas/data/note_repository.dart'; 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/note_card.dart'; import 'package:notas/widgets/search_app_bar.dart'; @@ -49,6 +50,8 @@ class _HomeScreenState extends State { bool _isMenuOpen = false; bool _showDeletedNotes = false; PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse; + List _categories = []; + String? _selectedCategoryId; void _openMenu() { if (_isMenuOpen) { @@ -80,6 +83,34 @@ class _HomeScreenState extends State { void initState() { super.initState(); _loadNotes(); + _loadCategories(); + } + + Future _loadCategories() async { + try { + final List cats = await widget.repository.loadCategories(); + if (!mounted) return; + setState(() { + _categories = cats; + }); + } catch (e) { + debugPrint('Failed to load categories: $e'); + } + } + + Category? _currentCategory() { + final String? selectedCategoryId = _selectedCategoryId; + if (selectedCategoryId == null) { + return null; + } + + for (final Category category in _categories) { + if (category.id == selectedCategoryId) { + return category; + } + } + + return null; } @override @@ -113,7 +144,10 @@ class _HomeScreenState extends State { } Future _openNoteComposer() async { - final dynamic result = await NoteEditorScreen.showDialog(context); + final dynamic result = await NoteEditorScreen.showDialog( + context, + categoryId: _showDeletedNotes ? null : _selectedCategoryId, + ); if (result == null) { return; @@ -188,12 +222,20 @@ class _HomeScreenState extends State { } List _getFilteredNotes() { + Iterable notes = _notes; + + if (_selectedCategoryId != null) { + notes = notes.where( + (Note note) => note.categoryId == _selectedCategoryId, + ); + } + if (_searchQuery.isEmpty) { - return _notes; + return notes.toList(); } final String query = _searchQuery.toLowerCase(); - return _notes + return notes .where( (Note note) => note.title.toLowerCase().contains(query) || @@ -205,6 +247,23 @@ class _HomeScreenState extends State { Future _handleMenuItemTapped(String item) async { _closeMenu(); + if (item == 'create_category') { + await _showCreateCategoryDialog(); + return; + } + + if (item.startsWith('category_')) { + final String id = item.substring('category_'.length); + setState(() { + _selectedCategoryId = id; + _showDeletedNotes = false; + _searchQuery = ''; + _isLoading = true; + }); + await _loadNotes(); + return; + } + if (item == 'settings') { widget.onOpenSettings(); return; @@ -213,6 +272,7 @@ class _HomeScreenState extends State { if (item == 'deleted_notes') { setState(() { _showDeletedNotes = true; + _selectedCategoryId = null; _searchQuery = ''; _isLoading = true; }); @@ -223,6 +283,7 @@ class _HomeScreenState extends State { if (item == 'all_notes') { setState(() { _showDeletedNotes = false; + _selectedCategoryId = null; _searchQuery = ''; _isLoading = true; }); @@ -230,15 +291,185 @@ class _HomeScreenState extends State { } } + Future _showCreateCategoryDialog() async { + final TextEditingController controller = TextEditingController(); + Color? selectedColor; + IconData? selectedIcon; + + 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, + ]; + + final bool? result = await showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext ctx, StateSetter setState) { + return AlertDialog( + title: const Text('Crear categoría'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + decoration: const InputDecoration( + hintText: 'Nombre de la categoría', + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Color', + style: const TextStyle( + fontSize: 14, + color: Colors.white70, + ), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: palette.map((Color color) { + final bool isSelected = selectedColor == color; + return GestureDetector( + onTap: () => setState(() => selectedColor = color), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(6), + border: isSelected + ? Border.all(color: Colors.white, width: 2) + : null, + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Icono', + style: const TextStyle( + fontSize: 14, + color: Colors.white70, + ), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: icons.map((IconData icon) { + final bool isSelected = selectedIcon == icon; + return GestureDetector( + onTap: () => setState(() => selectedIcon = icon), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isSelected + ? Colors.white10 + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + icon, + color: isSelected ? Colors.white : Colors.white70, + ), + ), + ); + }).toList(), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Crear'), + ), + ], + ); + }, + ); + }, + ); + + if (result != true || controller.text.trim().isEmpty) { + controller.dispose(); + return; + } + + try { + final Category newCategory = Category( + name: controller.text.trim(), + updatedAt: DateTime.now(), + colorValue: selectedColor?.toARGB32(), + iconCodePoint: selectedIcon?.codePoint, + ); + debugPrint('Creating category: ${newCategory.name}, color: ${newCategory.colorValue}, icon: ${newCategory.iconCodePoint}'); + await widget.repository.createCategory(newCategory); + await _loadCategories(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Categoría creada'))); + } + try { + await widget.onRequestSync(); + } catch (_) {} + } catch (e) { + debugPrint('ERROR creating category: $e'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error al crear categoría: $e'))); + } + } + controller.dispose(); + } + @override Widget build(BuildContext context) { final double width = MediaQuery.of(context).size.width; final int crossAxisCount = math.max((width / 250).floor().round(), 2); + final List visibleNotes = _getFilteredNotes(); + final Category? currentCategory = _currentCategory(); final Widget body = _isLoading ? const Center(child: CircularProgressIndicator()) - : _notes.isEmpty - ? _EmptyState(showDeletedNotes: _showDeletedNotes) + : visibleNotes.isEmpty + ? _EmptyState( + showDeletedNotes: _showDeletedNotes, + categoryName: currentCategory?.name, + searchQuery: _searchQuery, + ) : RefreshIndicator( onRefresh: () async { await widget.onRequestSync(); @@ -251,9 +482,9 @@ class _HomeScreenState extends State { crossAxisCount: crossAxisCount, mainAxisSpacing: 10, crossAxisSpacing: 10, - itemCount: _getFilteredNotes().length, + itemCount: visibleNotes.length, itemBuilder: (BuildContext context, int index) { - final List filteredNotes = _getFilteredNotes(); + final List filteredNotes = visibleNotes; return DragTarget( onAcceptWithDetails: (DragTargetDetails details) { final Note targetNote = filteredNotes[index]; @@ -357,7 +588,6 @@ class _HomeScreenState extends State { fontWeight: FontWeight.bold, ), maxLines: 2, - overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Text( @@ -640,9 +870,13 @@ class _HomeScreenState extends State { elevation: 8, child: MenuDrawer( onMenuItemTapped: _handleMenuItemTapped, - selectedItem: _showDeletedNotes - ? 'deleted_notes' - : 'all_notes', + selectedItem: _selectedCategoryId != null + ? 'category_${_selectedCategoryId}' + : (_showDeletedNotes + ? 'deleted_notes' + : 'all_notes'), + categories: _categories, + onCreateCategory: _showCreateCategoryDialog, ), ), ), @@ -668,9 +902,15 @@ class _HomeScreenState extends State { } class _EmptyState extends StatelessWidget { - const _EmptyState({required this.showDeletedNotes}); + const _EmptyState({ + required this.showDeletedNotes, + this.categoryName, + this.searchQuery, + }); final bool showDeletedNotes; + final String? categoryName; + final String? searchQuery; @override Widget build(BuildContext context) { @@ -681,7 +921,13 @@ class _EmptyState extends StatelessWidget { const Icon(Icons.note_add_outlined, color: Colors.white54, size: 48), const SizedBox(height: 12), Text( - showDeletedNotes ? 'No hay notas borradas' : 'Aún no hay notas', + searchQuery != null && searchQuery!.isNotEmpty + ? 'No hay resultados' + : showDeletedNotes + ? 'No hay notas borradas' + : categoryName != null + ? 'No hay notas en esta categoría' + : 'Aún no hay notas', style: const TextStyle( color: Colors.white, fontSize: 18, @@ -690,8 +936,12 @@ class _EmptyState extends StatelessWidget { ), const SizedBox(height: 8), Text( - showDeletedNotes + searchQuery != null && searchQuery!.isNotEmpty + ? 'Prueba a buscar con otro término o crea una nota nueva.' + : showDeletedNotes ? 'Las notas borradas aparecerán aquí para poder restaurarlas.' + : categoryName != null + ? 'Pulsa el botón + para crear una nota en “$categoryName”.' : 'Pulsa el botón + para crear la primera.', textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index 5901a73..704c303 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -14,15 +14,25 @@ import 'package:notas/platform/app_platform.dart'; // the user confirmed deletion. `null` indicates the user closed without saving. class NoteEditorScreen extends StatefulWidget { - const NoteEditorScreen({super.key, required this.note, this.onComplete}); + const NoteEditorScreen({ + super.key, + required this.note, + this.categoryId, + this.onComplete, + }); final Note? note; + final String? categoryId; final ValueChanged? onComplete; @override State createState() => _NoteEditorScreenState(); - static Future showDialog(BuildContext context, {Note? note}) { + static Future showDialog( + BuildContext context, { + Note? note, + String? categoryId, + }) { if (isAndroid || isIOS) { return showGeneralDialog( context: context, @@ -30,7 +40,7 @@ class NoteEditorScreen extends StatefulWidget { barrierColor: Colors.transparent, transitionDuration: const Duration(milliseconds: 200), pageBuilder: (context, animation, secondaryAnimation) { - return NoteEditorScreen(note: note); + return NoteEditorScreen(note: note, categoryId: categoryId); }, transitionBuilder: (context, animation, secondaryAnimation, child) { return ScaleTransition(scale: animation, child: child); @@ -46,7 +56,7 @@ class NoteEditorScreen extends StatefulWidget { barrierColor: Colors.transparent, transitionDuration: const Duration(milliseconds: 200), pageBuilder: (context, animation, secondaryAnimation) { - return NoteEditorScreen(note: note); + return NoteEditorScreen(note: note, categoryId: categoryId); }, transitionBuilder: (context, animation, secondaryAnimation, child) { return ScaleTransition(scale: animation, child: child); @@ -61,6 +71,7 @@ class NoteEditorScreen extends StatefulWidget { builder: (BuildContext overlayContext) { return NoteEditorScreen( note: note, + categoryId: categoryId, onComplete: (dynamic result) { if (!completer.isCompleted) { completer.complete(result); @@ -99,6 +110,7 @@ class _NoteEditorScreenState extends State { createdAt: now, updatedAt: now, position: 0, + categoryId: widget.categoryId, ); } else { _currentNote = widget.note!; diff --git a/lib/widgets/menu_drawer.dart b/lib/widgets/menu_drawer.dart index 7618f0f..f23a133 100644 --- a/lib/widgets/menu_drawer.dart +++ b/lib/widgets/menu_drawer.dart @@ -1,14 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:notas/models/category.dart'; class MenuDrawer extends StatelessWidget { const MenuDrawer({ super.key, this.onMenuItemTapped, this.selectedItem, + this.categories = const [], + this.onCreateCategory, }); final ValueChanged? onMenuItemTapped; final String? selectedItem; + final List categories; + final VoidCallback? onCreateCategory; @override Widget build(BuildContext context) { @@ -31,8 +36,36 @@ class MenuDrawer extends StatelessWidget { Expanded( child: SingleChildScrollView( child: Column( - children: const [ - SizedBox.shrink(), + children: [ + if (categories.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + children: categories.map((category) { + final categoryId = 'category_${category.id}'; + final IconData categoryIcon = category.iconCodePoint == null + ? Icons.folder_outlined + : IconData( + category.iconCodePoint!, + fontFamily: 'MaterialIcons', + ); + + return _MenuItemTile( + icon: categoryIcon, + label: category.name, + selected: selectedItem == categoryId, + onTap: () => onMenuItemTapped?.call(categoryId), + iconColor: Color(category.colorValue ?? 0xFFFFC107), + textColor: Color(category.colorValue ?? 0xFFFFC107), + ); + }).toList(), + ), + ), + _MenuItemTile( + icon: Icons.add_circle_outline, + label: 'Crear categoría', + onTap: onCreateCategory, + ), ], ), ), @@ -86,7 +119,6 @@ class _MenuItemTile extends StatelessWidget { duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, margin: EdgeInsets.only( - left: selected ? 0 : 8, right: 8, top: 2, bottom: 2,