Reestructuracion de la app

This commit is contained in:
2026-06-29 20:32:47 +02:00
parent 710be805ee
commit 82515960f6
6 changed files with 1040 additions and 1868 deletions
+77
View File
@@ -79,6 +79,79 @@ class _NotesAppState extends State<NotesApp>
ThemeData? _lightTheme; ThemeData? _lightTheme;
ThemeData? _darkTheme; ThemeData? _darkTheme;
bool _isSyncBannerVisible() {
switch (_syncStatus) {
case SyncStatus.preparing:
case SyncStatus.encrypting:
case SyncStatus.uploading:
case SyncStatus.waitingResponse:
case SyncStatus.decrypting:
case SyncStatus.syncing:
return true;
case SyncStatus.idle:
case SyncStatus.synced:
case SyncStatus.error:
return false;
}
}
Widget _buildSyncBanner(BuildContext context) {
if (!_isSyncBannerVisible()) {
return const SizedBox.shrink();
}
final AppPalette palette = _activePalette();
final String message = _syncErrorMessage ?? _syncDetailMessage ?? 'Sincronizando...';
final double? progress = _syncProgress;
return Material(
color: palette.surfaceElevated,
elevation: 12,
child: SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: palette.border)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.cloud_sync_outlined, color: palette.textSecondary, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 4,
value: progress,
backgroundColor: palette.borderMuted,
),
),
],
),
),
),
);
}
Brightness _effectiveBrightness() { Brightness _effectiveBrightness() {
switch (_themeMode) { switch (_themeMode) {
case ThemeMode.dark: case ThemeMode.dark:
@@ -964,6 +1037,10 @@ class _NotesAppState extends State<NotesApp>
child: activeScreen, child: activeScreen,
), ),
), ),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: _buildSyncBanner(context),
),
], ],
), ),
), ),
+4
View File
@@ -56,6 +56,10 @@ class NoteRepository {
return categories; return categories;
} }
Future<DateTime?> getLastSyncAt() async {
return _authApi.getLastSyncAt();
}
Future<void> createCategory(Category category) async { Future<void> createCategory(Category category) async {
debugPrint('createCategory called with: ${category.name}'); debugPrint('createCategory called with: ${category.name}');
+611 -1081
View File
File diff suppressed because it is too large Load Diff
+172 -584
View File
@@ -1,128 +1,51 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text; import 'package:flutter_quill/flutter_quill.dart';
import 'package:intl/intl.dart';
import 'package:notas/data/note_body.dart'; import 'package:notas/data/note_body.dart';
import 'package:notas/data/note_repository.dart';
import 'package:notas/models/category.dart'; import 'package:notas/models/category.dart';
import 'package:notas/models/note.dart'; import 'package:notas/models/note.dart';
import 'package:notas/platform/app_platform.dart';
import 'package:notas/theme/app_palette.dart'; import 'package:notas/theme/app_palette.dart';
import 'package:notas/widgets/category_style.dart'; import 'package:notas/widgets/category_style.dart';
// NoteEditorScreen: unified UI for creating and editing notes.
// - Use `NoteEditorScreen.showDialog(context, note: existing)` to edit.
// - Use `NoteEditorScreen.showDialog(context)` to create a new note.
// The screen returns either a `Note` (saved) or the string `'delete'` when
// the user confirmed deletion. `null` indicates the user closed without saving.
class NoteEditorScreen extends StatefulWidget { class NoteEditorScreen extends StatefulWidget {
const NoteEditorScreen({ const NoteEditorScreen({
super.key, super.key,
this.repository,
this.saveNote,
required this.note, required this.note,
this.categoryId,
this.categories = const <Category>[], this.categories = const <Category>[],
this.onComplete, this.embedded = false,
this.onSaved,
}); });
final Note? note; final NoteRepository? repository;
final String? categoryId; final Future<Note> Function(Note note)? saveNote;
final Note note;
final List<Category> categories; final List<Category> categories;
final ValueChanged<dynamic>? onComplete; final bool embedded;
final ValueChanged<Note>? onSaved;
@override @override
State<NoteEditorScreen> createState() => _NoteEditorScreenState(); State<NoteEditorScreen> createState() => _NoteEditorScreenState();
static Future<dynamic> _showGeneralEditorDialog(
BuildContext context, {
Note? note,
String? categoryId,
List<Category> categories = const <Category>[],
}) {
return showGeneralDialog<dynamic>(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 200),
pageBuilder: (context, animation, secondaryAnimation) {
return NoteEditorScreen(
note: note,
categoryId: categoryId,
categories: categories,
);
},
transitionBuilder: (context, animation, secondaryAnimation, child) {
return ScaleTransition(scale: animation, child: child);
},
);
}
static Future<dynamic> showDialog(
BuildContext context, {
Note? note,
String? categoryId,
List<Category> categories = const <Category>[],
}) {
if (isAndroid || isIOS) {
return _showGeneralEditorDialog(
context,
note: note,
categoryId: categoryId,
categories: categories,
);
}
final OverlayState? overlayState = Overlay.of(context, rootOverlay: true);
if (overlayState == null) {
return _showGeneralEditorDialog(
context,
note: note,
categoryId: categoryId,
categories: categories,
);
}
final Completer<dynamic> completer = Completer<dynamic>();
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (BuildContext overlayContext) {
return NoteEditorScreen(
note: note,
categoryId: categoryId,
categories: categories,
onComplete: (dynamic result) {
if (!completer.isCompleted) {
completer.complete(result);
}
if (entry.mounted) {
entry.remove();
}
},
);
},
);
overlayState.insert(entry);
return completer.future;
}
} }
class _NoteEditorScreenState extends State<NoteEditorScreen> { class _NoteEditorScreenState extends State<NoteEditorScreen> {
late TextEditingController _titleController; static const Duration _debounceDuration = Duration(seconds: 1);
late QuillController _bodyController;
late FocusNode _bodyFocusNode;
late ScrollController _bodyScrollController;
late Note _currentNote;
late bool _isNewNote;
String? _selectedCategoryId;
final GlobalKey _categorySelectorKey = GlobalKey(); final GlobalKey _categorySelectorKey = GlobalKey();
OverlayEntry? _categoryMenuEntry;
bool _didComplete = false;
bool get _isMobileLayout => isAndroid || isIOS; late final TextEditingController _titleController;
late final QuillController _bodyController;
late final FocusNode _bodyFocusNode;
late final ScrollController _bodyScrollController;
Timer? _debounceTimer;
bool _isSaving = false;
bool _saveQueued = false;
late Note _baselineNote;
String? _selectedCategoryId;
AppPalette _paletteOf(BuildContext context) { AppPalette _paletteOf(BuildContext context) {
return Theme.of(context).extension<AppPalette>() ?? return Theme.of(context).extension<AppPalette>() ??
@@ -132,198 +55,30 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isNewNote = widget.note == null; _baselineNote = widget.note;
_selectedCategoryId = widget.note.categoryId;
if (_isNewNote) { _titleController = TextEditingController(text: widget.note.title)
final DateTime now = DateTime.now(); ..addListener(_scheduleSave);
_currentNote = Note(
title: '',
body: '',
createdAt: now,
updatedAt: now,
position: 0,
categoryId: widget.categoryId,
);
} else {
_currentNote = widget.note!;
}
_selectedCategoryId = _currentNote.categoryId ?? widget.categoryId;
_titleController = TextEditingController(text: _currentNote.title);
_bodyController = QuillController( _bodyController = QuillController(
document: noteBodyToDocument(_currentNote.body), document: noteBodyToDocument(widget.note.body),
selection: const TextSelection.collapsed(offset: 0), selection: const TextSelection.collapsed(offset: 0),
); )..addListener(_scheduleSave);
_bodyFocusNode = FocusNode(); _bodyFocusNode = FocusNode();
_bodyScrollController = ScrollController(); _bodyScrollController = ScrollController();
} }
@override @override
void dispose() { void dispose() {
_closeCategoryMenu(); _debounceTimer?.cancel();
_titleController.dispose(); _titleController.dispose();
_bodyController.dispose();
_bodyFocusNode.dispose(); _bodyFocusNode.dispose();
_bodyScrollController.dispose(); _bodyScrollController.dispose();
_bodyController.dispose();
super.dispose(); super.dispose();
} }
void _complete(dynamic result) { String _bodyAsJson() {
if (_didComplete) { return noteDocumentToStorageJson(_bodyController.document);
return;
}
_didComplete = true;
_closeCategoryMenu();
final ValueChanged<dynamic>? callback = widget.onComplete;
if (callback != null) {
callback(result);
return;
}
Navigator.of(context).pop(result);
}
void _closeWithoutSaving() {
_complete(null);
}
void _saveNote() {
final String title = _titleController.text.trim();
final String bodyPlainText = _bodyController.document.toPlainText().trim();
final bool categoryChanged = _selectedCategoryId != _currentNote.categoryId;
if (title.isEmpty && bodyPlainText.isEmpty && !categoryChanged) {
_complete(null);
return;
}
final Note updatedNote = _currentNote.copyWith(
title: title.isEmpty ? 'Sin título' : title,
body: noteDocumentToStorageJson(_bodyController.document),
categoryId: _selectedCategoryId,
updatedAt: DateTime.now(),
isDirty: true,
);
_complete(updatedNote);
}
Widget _buildDeleteConfirmationDialog({
required ValueChanged<bool> onConfirmed,
}) {
final bool isDeletedNote = _currentNote.isDeleted;
final AppPalette palette = _paletteOf(context);
return AlertDialog(
backgroundColor: palette.cardBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: palette.border),
),
title: Text(
isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar nota',
style: TextStyle(color: palette.textPrimary),
),
content: Text(
isDeletedNote
? 'Esta nota ya está borrada. Si la eliminas ahora, se borrará permanentemente.'
: '¿Estás seguro de que deseas eliminar esta nota?',
style: TextStyle(color: palette.textSecondary),
),
actions: [
TextButton(
onPressed: () => onConfirmed(false),
child: Text(
'Cancelar',
style: TextStyle(color: palette.textSecondary),
),
),
TextButton(
onPressed: () => onConfirmed(true),
child: Text(
isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar',
style: TextStyle(color: palette.destructiveAccent),
),
),
],
);
}
Future<bool> _showDeleteConfirmation() async {
if (_isMobileLayout) {
final bool? confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext dialogContext) {
return _buildDeleteConfirmationDialog(
onConfirmed: (bool confirmed) =>
Navigator.of(dialogContext).pop(confirmed),
);
},
);
return confirmed ?? false;
}
final OverlayState? overlayState = Overlay.of(context, rootOverlay: true);
if (overlayState == null) {
final bool? confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext dialogContext) {
return _buildDeleteConfirmationDialog(
onConfirmed: (bool confirmed) =>
Navigator.of(dialogContext).pop(confirmed),
);
},
);
return confirmed ?? false;
}
final Completer<bool> completer = Completer<bool>();
late final OverlayEntry entry;
bool didRemove = false;
entry = OverlayEntry(
builder: (BuildContext overlayContext) {
final ValueChanged<bool> close = (bool confirmed) {
if (!completer.isCompleted) {
completer.complete(confirmed);
}
if (!didRemove && entry.mounted) {
didRemove = true;
entry.remove();
}
};
return Material(
color: Colors.transparent,
child: Stack(
children: [
const Positioned.fill(
child: ModalBarrier(dismissible: false, color: Colors.black54),
),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: _buildDeleteConfirmationDialog(onConfirmed: close),
),
),
],
),
);
},
);
overlayState.insert(entry);
return completer.future;
} }
Category? _categoryById(String? id) { Category? _categoryById(String? id) {
@@ -359,306 +114,204 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
: palette.textPrimary; : palette.textPrimary;
} }
Widget _buildCategorySelectorBox({Category? category}) { RelativeRect _menuRectFromContext(BuildContext anchorContext) {
final AppPalette palette = _paletteOf(context); final RenderBox button = anchorContext.findRenderObject()! as RenderBox;
final String label = category?.name ?? 'Sin categoría'; final RenderBox overlay = Overlay.of(anchorContext).context.findRenderObject()!
final IconData icon = CategoryStyle.iconForCodePoint( as RenderBox;
category?.iconCodePoint, final Offset topLeft = button.localToGlobal(Offset.zero, ancestor: overlay);
final Offset bottomRight = button.localToGlobal(
button.size.bottomRight(Offset.zero),
ancestor: overlay,
); );
final Color backgroundColor = _categoryBackgroundColor(category);
final Color foregroundColor = _categoryForegroundColor(category);
return Container( return RelativeRect.fromRect(
key: _categorySelectorKey, Rect.fromLTRB(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), topLeft.dx,
decoration: BoxDecoration( topLeft.dy,
color: backgroundColor, bottomRight.dx,
borderRadius: BorderRadius.circular(10), bottomRight.dy,
border: Border.all(
color: category?.colorValue != null
? backgroundColor.withValues(alpha: 0.85)
: palette.textDisabled,
),
),
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,
),
],
), ),
Offset.zero & overlay.size,
); );
} }
void _closeCategoryMenu() { void _scheduleSave() {
final OverlayEntry? entry = _categoryMenuEntry; _debounceTimer?.cancel();
if (entry != null && entry.mounted) { _debounceTimer = Timer(_debounceDuration, () {
entry.remove(); unawaited(_saveNow());
} });
_categoryMenuEntry = null;
} }
void _toggleCategoryMenu() { Future<void> _saveNow() async {
if (_categoryMenuEntry != null) { if (!mounted) {
_closeCategoryMenu();
return; return;
} }
final OverlayState? overlayState = Overlay.of(context, rootOverlay: true); final String title = _titleController.text.trim();
if (overlayState == null) { final String body = _bodyAsJson();
final Note draft = _baselineNote.copyWith(
title: title.isEmpty ? 'Sin título' : title,
body: body,
categoryId: _selectedCategoryId,
updatedAt: DateTime.now(),
isDirty: true,
);
final bool hasChanges = draft.title != _baselineNote.title ||
draft.body != _baselineNote.body ||
draft.categoryId != _baselineNote.categoryId;
if (!hasChanges) {
return; return;
} }
_categoryMenuEntry = OverlayEntry( if (_isSaving) {
builder: (BuildContext overlayContext) { _saveQueued = true;
final AppPalette palette = _paletteOf(overlayContext); return;
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( _isSaving = true;
color: Colors.transparent, try {
child: Stack( final Note saved = widget.saveNote != null
children: [ ? await widget.saveNote!(draft)
Positioned.fill( : await widget.repository!.updateNote(draft);
child: GestureDetector( _baselineNote = saved;
behavior: HitTestBehavior.translucent, widget.onSaved?.call(saved);
onTap: _closeCategoryMenu, } catch (error) {
child: const SizedBox.expand(), if (mounted) {
), ScaffoldMessenger.of(context).showSnackBar(
), SnackBar(content: Text('No se pudo guardar la nota: $error')),
Center( );
child: ConstrainedBox( }
constraints: BoxConstraints( } finally {
maxWidth: menuWidth, _isSaving = false;
maxHeight: menuHeight, if (_saveQueued) {
), _saveQueued = false;
child: Material( unawaited(_saveNow());
}
}
}
Future<void> _selectCategory(BuildContext anchorContext) async {
final Category? selected = await showMenu<Category?>(
context: anchorContext,
position: _menuRectFromContext(anchorContext),
elevation: 10, elevation: 10,
color: palette.surfaceElevated, color: _paletteOf(anchorContext).surfaceElevated,
borderRadius: BorderRadius.circular(12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
clipBehavior: Clip.antiAlias, items: <PopupMenuEntry<Category?>>[
child: ListView( const PopupMenuItem<Category?>(
padding: const EdgeInsets.symmetric(vertical: 8), value: null,
shrinkWrap: true, child: Text('Sin categoría'),
children: [
_buildCategoryMenuItem(
category: null,
label: 'Sin categoría',
isSelected: _selectedCategoryId == null,
onTap: () {
setState(() {
_selectedCategoryId = null;
});
_closeCategoryMenu();
},
), ),
const PopupMenuDivider(),
for (final Category category in widget.categories) for (final Category category in widget.categories)
_buildCategoryMenuItem( PopupMenuItem<Category?>(
category: category, value: category,
label: category.name, child: Text(category.name),
isSelected: _selectedCategoryId == category.id,
onTap: () {
setState(() {
_selectedCategoryId = category.id;
});
_closeCategoryMenu();
},
), ),
], ],
),
),
),
),
],
),
);
},
); );
overlayState.insert(_categoryMenuEntry!); if (!mounted) {
return;
} }
Widget _buildCategoryMenuItem({ setState(() {
required Category? category, _selectedCategoryId = selected?.id;
required String label, });
required bool isSelected, _scheduleSave();
required VoidCallback onTap, }
}) {
Widget _buildCategorySelector() {
final Category? category = _categoryById(_selectedCategoryId);
final AppPalette palette = _paletteOf(context); final AppPalette palette = _paletteOf(context);
final Color backgroundColor = _categoryBackgroundColor(category); final Color backgroundColor = _categoryBackgroundColor(category);
final Color foregroundColor = _categoryForegroundColor(category); final Color foregroundColor = _categoryForegroundColor(category);
final IconData icon = CategoryStyle.iconForCodePoint(
category?.iconCodePoint,
);
return InkWell( return InkWell(
onTap: onTap, key: const ValueKey<String>('category_selector'),
borderRadius: BorderRadius.circular(12),
onTap: () => _selectCategory(context),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), key: _categorySelectorKey,
color: isSelected ? palette.hover : null, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: category?.colorValue != null color: category?.colorValue != null
? backgroundColor.withValues(alpha: 0.85) ? backgroundColor.withValues(alpha: 0.9)
: palette.textDisabled, : palette.textDisabled,
), ),
), ),
child: Icon(icon, size: 16, color: foregroundColor), child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CategoryStyle.iconForCodePoint(category?.iconCodePoint),
color: foregroundColor,
size: 16,
), ),
const SizedBox(width: 12), const SizedBox(width: 8),
Expanded( Flexible(
child: Text( child: Text(
label, category?.name ?? 'Sin categoría',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: foregroundColor, color: foregroundColor,
fontSize: 13, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
), ),
if (isSelected) Icon(Icons.check, color: foregroundColor, size: 18), const SizedBox(width: 6),
Icon(Icons.arrow_drop_down, color: foregroundColor, size: 18),
], ],
), ),
), ),
); );
} }
Future<void> _deleteNote() async { Widget _buildEditorBody() {
final bool confirmed = await _showDeleteConfirmation();
if (!mounted || !confirmed) {
return;
}
_complete('delete');
}
String _formatDate(DateTime date) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime yesterday = today.subtract(const Duration(days: 1));
final DateTime noteDate = DateTime(date.year, date.month, date.day);
if (noteDate == today) {
return 'Hoy ${DateFormat('HH:mm').format(date)}';
} else if (noteDate == yesterday) {
return 'Ayer ${DateFormat('HH:mm').format(date)}';
} else {
return DateFormat('dd/MM/yyyy HH:mm').format(date);
}
}
Widget _buildEditorContent({required bool isMobile}) {
final AppPalette palette = _paletteOf(context); final AppPalette palette = _paletteOf(context);
final double titleSpacing = isMobile ? 16.0 : 8.0;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Row(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: palette.border, width: 1)),
),
child: Row(
children: [ children: [
IconButton(
onPressed: _closeWithoutSaving,
icon: Icon(Icons.close, color: palette.textSecondary),
tooltip: 'Cerrar sin guardar',
),
const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: TextField(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Posicion: ${_currentNote.position}',
style: TextStyle(color: palette.textMuted, fontSize: 12),
),
Text(
'Creado: ${_formatDate(_currentNote.createdAt)}',
style: TextStyle(color: palette.textMuted, fontSize: 12),
),
if (_currentNote.updatedAt != _currentNote.createdAt)
Text(
'Modificado: ${_formatDate(_currentNote.updatedAt)}',
style: TextStyle(
color: palette.textMuted,
fontSize: 12,
),
),
],
),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 150),
child: SizedBox(
width: 150,
child: KeyedSubtree(
key: const ValueKey<String>('category_selector'),
child: InkWell(
onTap: _toggleCategoryMenu,
borderRadius: BorderRadius.circular(12),
child: _buildCategorySelectorBox(
category: _categoryById(_selectedCategoryId),
),
),
),
),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _titleController, controller: _titleController,
style: TextStyle( style: TextStyle(
color: palette.textPrimary, color: palette.textPrimary,
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Título', hintText: 'Título',
hintStyle: TextStyle(color: palette.textHint), hintStyle: TextStyle(color: palette.textHint),
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.zero,
), ),
), ),
SizedBox(height: titleSpacing), ),
const SizedBox(width: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: _buildCategorySelector(),
),
],
),
const SizedBox(height: 16),
Expanded( Expanded(
child: Padding( child: Container(
padding: const EdgeInsets.only(top: 4), decoration: BoxDecoration(
color: palette.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.border),
),
padding: const EdgeInsets.all(14),
child: QuillEditor.basic( child: QuillEditor.basic(
controller: _bodyController, controller: _bodyController,
focusNode: _bodyFocusNode, focusNode: _bodyFocusNode,
@@ -674,18 +327,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
), ),
), ),
), ),
], const SizedBox(height: 12),
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: palette.border, width: 1)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
QuillSimpleToolbar( QuillSimpleToolbar(
controller: _bodyController, controller: _bodyController,
config: const QuillSimpleToolbarConfig( config: const QuillSimpleToolbarConfig(
@@ -717,36 +359,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
showSubscript: false, showSubscript: false,
showSuperscript: false, showSuperscript: false,
multiRowsDisplay: false, multiRowsDisplay: false,
showClipboardCut: false,
showClipboardCopy: false,
showClipboardPaste: false,
axis: Axis.horizontal, axis: Axis.horizontal,
), ),
), ),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!_isNewNote)
IconButton(
onPressed: _deleteNote,
icon: Icon(
Icons.delete_outline,
color: palette.destructiveAccent,
),
tooltip: 'Eliminar nota',
)
else
const SizedBox(width: 48),
FilledButton(
onPressed: _saveNote,
child: const Text('Guardar'),
),
],
),
],
),
),
], ],
); );
} }
@@ -755,48 +370,21 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AppPalette palette = _paletteOf(context); final AppPalette palette = _paletteOf(context);
if (_isMobileLayout) { final Widget editor = Padding(
return Material( padding: const EdgeInsets.all(20),
color: palette.transparent, child: _buildEditorBody(),
child: SafeArea(
child: Container(
color: palette.cardBackground,
child: _buildEditorContent(isMobile: true),
),
),
); );
if (widget.embedded) {
return Container(color: palette.cardBackground, child: editor);
} }
return LayoutBuilder( return Scaffold(
builder: (BuildContext context, BoxConstraints constraints) { backgroundColor: palette.cardBackground,
final double maxWidth = math.min(constraints.maxWidth - 32, 600); appBar: AppBar(
final double maxHeight = math.min(constraints.maxHeight - 32, 720); title: const Text('Editar nota'),
return Stack(
children: [
Positioned.fill(
child: ModalBarrier(dismissible: false, color: palette.shadowDim),
), ),
Positioned.fill( body: SafeArea(child: editor),
child: Center(
child: SizedBox(
width: maxWidth,
height: maxHeight,
child: Material(
color: palette.cardBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: palette.textDisabled, width: 1),
),
clipBehavior: Clip.antiAlias,
child: _buildEditorContent(isMobile: false),
),
),
),
),
],
);
},
); );
} }
} }
+71 -111
View File
@@ -4,147 +4,107 @@ import 'package:notas/data/note_body.dart';
import 'package:notas/models/note.dart'; import 'package:notas/models/note.dart';
import 'package:notas/theme/app_palette.dart'; import 'package:notas/theme/app_palette.dart';
// Small presentational widget for a note inside the grid. class NoteCard extends StatelessWidget {
// Keep this widget lightweight and layout-agnostic: it should not force
// width/height constraints (so it works inside different parent layouts
// like MasonryGridView or Draggable feedback). Visual styling only.
class NoteCard extends StatefulWidget {
const NoteCard({ const NoteCard({
super.key, super.key,
required this.note, required this.note,
this.onTap, this.isSelected = false,
this.isDragging = false,
this.borderColor, this.borderColor,
this.onTap,
this.onDelete,
this.onChangeCategory,
}); });
final Note note; final Note note;
final VoidCallback? onTap; final bool isSelected;
final bool isDragging;
final Color? borderColor; final Color? borderColor;
final VoidCallback? onTap;
@override final VoidCallback? onDelete;
State<NoteCard> createState() => _NoteCardState(); final ValueChanged<BuildContext>? onChangeCategory;
}
class _NoteCardState extends State<NoteCard> {
bool _isPressed = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AppPalette palette = Theme.of(context).extension<AppPalette>()!; final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
final bool showGrabbing = widget.isDragging || _isPressed; final String bodyText = noteBodyToPlainText(note.body).trim();
return MouseRegion( return Material(
cursor: showGrabbing color: Colors.transparent, // 1. Fondo completamente transparente
? SystemMouseCursors.grabbing shape: BorderDirectional(
: SystemMouseCursors.grab, start: BorderSide(
child: GestureDetector( color: isSelected ? palette.accent : Colors.transparent,
onTapDown: widget.onTap == null width: isSelected ? 1.6 : 1.0,
? null
: (_) {
setState(() {
_isPressed = true;
});
},
onTapUp: widget.onTap == null
? null
: (_) {
setState(() {
_isPressed = false;
});
},
onTapCancel: widget.onTap == null
? null
: () {
setState(() {
_isPressed = false;
});
},
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.cardBackground,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: widget.borderColor ?? palette.textDisabled,
width: 1,
), ),
), ),
child: LayoutBuilder( child: InkWell(
builder: (BuildContext context, BoxConstraints constraints) { borderRadius: BorderRadius.circular(14),
// Estimate whether the body will exceed 20 lines without always onTap: onTap,
// running the expensive TextPainter layout. This heuristic counts hoverColor: Colors.transparent, // 2. Desactiva el efecto hover (pasar el ratón)
// newline characters and estimates wrapped lines based on an splashColor: Colors.transparent, // 3. Desactiva el efecto de onda al hacer clic
// average characters-per-line to handle many short lines well. highlightColor: Colors.transparent, // Desactiva el brillo al mantener pulsado
final String bodyText = noteBodyToPlainText(widget.note.body); child: Padding(
final List<String> rawLines = bodyText.split('\n'); padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
const int avgCharsPerLine = 40; child: Row(
int estimatedLines = 0;
for (final String line in rawLines) {
estimatedLines += (line.trim().length ~/ avgCharsPerLine) + 1;
}
final bool needsPreciseMeasurement = estimatedLines > 15;
final bool isBodyTruncated;
if (needsPreciseMeasurement) {
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: bodyText,
style: TextStyle(
color: palette.textSecondary,
fontSize: 14,
),
),
maxLines: 15,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
isBodyTruncated = textPainter.didExceedMaxLines;
} else {
isBodyTruncated = false;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
widget.note.title, note.title.isEmpty ? 'Sin título' : note.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: palette.textPrimary, color: palette.textPrimary,
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
Text( Text(
bodyText, bodyText.isEmpty ? ' ' : bodyText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: palette.textSecondary, color: palette.textSecondary,
fontSize: 14, fontSize: 13,
), height: 1.2,
maxLines: 15,
overflow: TextOverflow.clip,
),
if (isBodyTruncated) ...[
const SizedBox(height: 4),
Text(
'...',
style: TextStyle(
color: palette.textMuted,
fontSize: 18,
height: 1,
), ),
), ),
], ],
], ),
); ),
const SizedBox(width: 8),
PopupMenuButton<String>(
tooltip: 'Más opciones',
icon: Icon(
Icons.more_vert,
color: palette.textSecondary,
),
onOpened: () {},
onSelected: (String value) {
switch (value) {
case 'delete':
onDelete?.call();
return;
case 'category':
onChangeCategory?.call(context);
return;
}
}, },
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'delete',
child: Text('Eliminar nota'),
),
const PopupMenuItem<String>(
value: 'category',
child: Text('Cambiar categoría'),
),
],
),
],
), ),
), ),
), ),
+36 -23
View File
@@ -8,11 +8,19 @@ import 'package:notas/models/note.dart';
import 'package:notas/screens/note_editor_screen.dart'; import 'package:notas/screens/note_editor_screen.dart';
void main() { void main() {
testWidgets('saves a note when only the category changes', ( testWidgets('autosaves a note when only the category changes', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
Note? savedNote; Note? savedNote;
final Note initialNote = Note(
title: 'Sin título',
body: '',
createdAt: DateTime(2026, 5, 21),
updatedAt: DateTime(2026, 5, 21),
position: 0,
);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[ localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
@@ -23,8 +31,8 @@ void main() {
], ],
home: Scaffold( home: Scaffold(
body: NoteEditorScreen( body: NoteEditorScreen(
note: null, repository: null,
categoryId: null, note: initialNote,
categories: <Category>[ categories: <Category>[
Category( Category(
id: 'work', id: 'work',
@@ -32,8 +40,9 @@ void main() {
updatedAt: DateTime(2026, 5, 21), updatedAt: DateTime(2026, 5, 21),
), ),
], ],
onComplete: (dynamic result) { saveNote: (Note note) async => note,
savedNote = result as Note?; onSaved: (Note result) {
savedNote = result;
}, },
), ),
), ),
@@ -46,20 +55,26 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Trabajo').last); await tester.tap(find.text('Trabajo').last);
await tester.pumpAndSettle(); await tester.pump();
await tester.pump(const Duration(seconds: 2));
await tester.tap(find.text('Guardar'));
await tester.pumpAndSettle();
expect(savedNote, isNotNull); expect(savedNote, isNotNull);
expect(savedNote!.categoryId, 'work'); expect(savedNote!.categoryId, 'work');
expect(savedNote!.title, 'Sin título'); expect(savedNote!.title, 'Sin título');
}); });
testWidgets('only completes once when save is tapped twice', ( testWidgets('debounces multiple edits into a single save', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
int completionCount = 0; int saveCount = 0;
final Note initialNote = Note(
title: 'Sin título',
body: '',
createdAt: DateTime(2026, 5, 21),
updatedAt: DateTime(2026, 5, 21),
position: 0,
);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@@ -71,25 +86,23 @@ void main() {
], ],
home: Scaffold( home: Scaffold(
body: NoteEditorScreen( body: NoteEditorScreen(
note: null, repository: null,
categoryId: null, note: initialNote,
categories: <Category>[], categories: <Category>[],
onComplete: (dynamic result) { saveNote: (Note note) async {
if (result is Note) { saveCount += 1;
completionCount += 1; return note;
}
}, },
), ),
), ),
), ),
); );
await tester.enterText(find.byType(TextField).first, 'Nota de prueba'); await tester.enterText(find.byType(TextField).first, 'Primera versión');
await tester.pump(const Duration(milliseconds: 300));
await tester.enterText(find.byType(TextField).first, 'Segunda versión');
await tester.pump(const Duration(seconds: 2));
await tester.tap(find.text('Guardar')); expect(saveCount, 1);
await tester.tap(find.text('Guardar'));
await tester.pumpAndSettle();
expect(completionCount, 1);
}); });
} }