Reestructuracion de la app
This commit is contained in:
@@ -79,6 +79,79 @@ class _NotesAppState extends State<NotesApp>
|
||||
ThemeData? _lightTheme;
|
||||
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() {
|
||||
switch (_themeMode) {
|
||||
case ThemeMode.dark:
|
||||
@@ -964,6 +1037,10 @@ class _NotesAppState extends State<NotesApp>
|
||||
child: activeScreen,
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: _buildSyncBanner(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -56,6 +56,10 @@ class NoteRepository {
|
||||
return categories;
|
||||
}
|
||||
|
||||
Future<DateTime?> getLastSyncAt() async {
|
||||
return _authApi.getLastSyncAt();
|
||||
}
|
||||
|
||||
Future<void> createCategory(Category category) async {
|
||||
debugPrint('createCategory called with: ${category.name}');
|
||||
|
||||
|
||||
+611
-1081
File diff suppressed because it is too large
Load Diff
+172
-584
@@ -1,128 +1,51 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' hide Text;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:flutter_quill/flutter_quill.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/note.dart';
|
||||
import 'package:notas/platform/app_platform.dart';
|
||||
import 'package:notas/theme/app_palette.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 {
|
||||
const NoteEditorScreen({
|
||||
super.key,
|
||||
this.repository,
|
||||
this.saveNote,
|
||||
required this.note,
|
||||
this.categoryId,
|
||||
this.categories = const <Category>[],
|
||||
this.onComplete,
|
||||
this.embedded = false,
|
||||
this.onSaved,
|
||||
});
|
||||
|
||||
final Note? note;
|
||||
final String? categoryId;
|
||||
final NoteRepository? repository;
|
||||
final Future<Note> Function(Note note)? saveNote;
|
||||
final Note note;
|
||||
final List<Category> categories;
|
||||
final ValueChanged<dynamic>? onComplete;
|
||||
final bool embedded;
|
||||
final ValueChanged<Note>? onSaved;
|
||||
|
||||
@override
|
||||
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> {
|
||||
late TextEditingController _titleController;
|
||||
late QuillController _bodyController;
|
||||
late FocusNode _bodyFocusNode;
|
||||
late ScrollController _bodyScrollController;
|
||||
late Note _currentNote;
|
||||
late bool _isNewNote;
|
||||
String? _selectedCategoryId;
|
||||
static const Duration _debounceDuration = Duration(seconds: 1);
|
||||
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) {
|
||||
return Theme.of(context).extension<AppPalette>() ??
|
||||
@@ -132,198 +55,30 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isNewNote = widget.note == null;
|
||||
|
||||
if (_isNewNote) {
|
||||
final DateTime now = DateTime.now();
|
||||
_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);
|
||||
_baselineNote = widget.note;
|
||||
_selectedCategoryId = widget.note.categoryId;
|
||||
_titleController = TextEditingController(text: widget.note.title)
|
||||
..addListener(_scheduleSave);
|
||||
_bodyController = QuillController(
|
||||
document: noteBodyToDocument(_currentNote.body),
|
||||
document: noteBodyToDocument(widget.note.body),
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
)..addListener(_scheduleSave);
|
||||
_bodyFocusNode = FocusNode();
|
||||
_bodyScrollController = ScrollController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_closeCategoryMenu();
|
||||
_debounceTimer?.cancel();
|
||||
_titleController.dispose();
|
||||
_bodyController.dispose();
|
||||
_bodyFocusNode.dispose();
|
||||
_bodyScrollController.dispose();
|
||||
_bodyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _complete(dynamic result) {
|
||||
if (_didComplete) {
|
||||
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;
|
||||
String _bodyAsJson() {
|
||||
return noteDocumentToStorageJson(_bodyController.document);
|
||||
}
|
||||
|
||||
Category? _categoryById(String? id) {
|
||||
@@ -359,306 +114,204 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||
: palette.textPrimary;
|
||||
}
|
||||
|
||||
Widget _buildCategorySelectorBox({Category? category}) {
|
||||
final AppPalette palette = _paletteOf(context);
|
||||
final String label = category?.name ?? 'Sin categoría';
|
||||
final IconData icon = CategoryStyle.iconForCodePoint(
|
||||
category?.iconCodePoint,
|
||||
RelativeRect _menuRectFromContext(BuildContext anchorContext) {
|
||||
final RenderBox button = anchorContext.findRenderObject()! as RenderBox;
|
||||
final RenderBox overlay = Overlay.of(anchorContext).context.findRenderObject()!
|
||||
as RenderBox;
|
||||
final Offset topLeft = button.localToGlobal(Offset.zero, ancestor: overlay);
|
||||
final Offset bottomRight = button.localToGlobal(
|
||||
button.size.bottomRight(Offset.zero),
|
||||
ancestor: overlay,
|
||||
);
|
||||
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)
|
||||
: 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,
|
||||
),
|
||||
],
|
||||
return RelativeRect.fromRect(
|
||||
Rect.fromLTRB(
|
||||
topLeft.dx,
|
||||
topLeft.dy,
|
||||
bottomRight.dx,
|
||||
bottomRight.dy,
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
}
|
||||
|
||||
void _closeCategoryMenu() {
|
||||
final OverlayEntry? entry = _categoryMenuEntry;
|
||||
if (entry != null && entry.mounted) {
|
||||
entry.remove();
|
||||
}
|
||||
_categoryMenuEntry = null;
|
||||
void _scheduleSave() {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(_debounceDuration, () {
|
||||
unawaited(_saveNow());
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleCategoryMenu() {
|
||||
if (_categoryMenuEntry != null) {
|
||||
_closeCategoryMenu();
|
||||
Future<void> _saveNow() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final OverlayState? overlayState = Overlay.of(context, rootOverlay: true);
|
||||
if (overlayState == null) {
|
||||
final String title = _titleController.text.trim();
|
||||
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;
|
||||
}
|
||||
|
||||
_categoryMenuEntry = OverlayEntry(
|
||||
builder: (BuildContext overlayContext) {
|
||||
final AppPalette palette = _paletteOf(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);
|
||||
if (_isSaving) {
|
||||
_saveQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
_isSaving = true;
|
||||
try {
|
||||
final Note saved = widget.saveNote != null
|
||||
? await widget.saveNote!(draft)
|
||||
: await widget.repository!.updateNote(draft);
|
||||
_baselineNote = saved;
|
||||
widget.onSaved?.call(saved);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('No se pudo guardar la nota: $error')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_isSaving = false;
|
||||
if (_saveQueued) {
|
||||
_saveQueued = false;
|
||||
unawaited(_saveNow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectCategory(BuildContext anchorContext) async {
|
||||
final Category? selected = await showMenu<Category?>(
|
||||
context: anchorContext,
|
||||
position: _menuRectFromContext(anchorContext),
|
||||
elevation: 10,
|
||||
color: palette.surfaceElevated,
|
||||
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();
|
||||
},
|
||||
color: _paletteOf(anchorContext).surfaceElevated,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
items: <PopupMenuEntry<Category?>>[
|
||||
const PopupMenuItem<Category?>(
|
||||
value: null,
|
||||
child: Text('Sin categoría'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
for (final Category category in widget.categories)
|
||||
_buildCategoryMenuItem(
|
||||
category: category,
|
||||
label: category.name,
|
||||
isSelected: _selectedCategoryId == category.id,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedCategoryId = category.id;
|
||||
});
|
||||
_closeCategoryMenu();
|
||||
},
|
||||
PopupMenuItem<Category?>(
|
||||
value: category,
|
||||
child: Text(category.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlayState.insert(_categoryMenuEntry!);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
Widget _buildCategoryMenuItem({
|
||||
required Category? category,
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
setState(() {
|
||||
_selectedCategoryId = selected?.id;
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
Widget _buildCategorySelector() {
|
||||
final Category? category = _categoryById(_selectedCategoryId);
|
||||
final AppPalette palette = _paletteOf(context);
|
||||
final Color backgroundColor = _categoryBackgroundColor(category);
|
||||
final Color foregroundColor = _categoryForegroundColor(category);
|
||||
final IconData icon = CategoryStyle.iconForCodePoint(
|
||||
category?.iconCodePoint,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
key: const ValueKey<String>('category_selector'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _selectCategory(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
color: isSelected ? palette.hover : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
key: _categorySelectorKey,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: category?.colorValue != null
|
||||
? backgroundColor.withValues(alpha: 0.85)
|
||||
? backgroundColor.withValues(alpha: 0.9)
|
||||
: 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),
|
||||
Expanded(
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
category?.name ?? 'Sin categoría',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
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 {
|
||||
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}) {
|
||||
Widget _buildEditorBody() {
|
||||
final AppPalette palette = _paletteOf(context);
|
||||
final double titleSpacing = isMobile ? 16.0 : 8.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: palette.border, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _closeWithoutSaving,
|
||||
icon: Icon(Icons.close, color: palette.textSecondary),
|
||||
tooltip: 'Cerrar sin guardar',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
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(
|
||||
child: TextField(
|
||||
controller: _titleController,
|
||||
style: TextStyle(
|
||||
color: palette.textPrimary,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Título',
|
||||
hintStyle: TextStyle(color: palette.textHint),
|
||||
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(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: palette.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: palette.border),
|
||||
),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: QuillEditor.basic(
|
||||
controller: _bodyController,
|
||||
focusNode: _bodyFocusNode,
|
||||
@@ -674,18 +327,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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: [
|
||||
const SizedBox(height: 12),
|
||||
QuillSimpleToolbar(
|
||||
controller: _bodyController,
|
||||
config: const QuillSimpleToolbarConfig(
|
||||
@@ -717,36 +359,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||
showSubscript: false,
|
||||
showSuperscript: false,
|
||||
multiRowsDisplay: false,
|
||||
showClipboardCut: false,
|
||||
showClipboardCopy: false,
|
||||
showClipboardPaste: false,
|
||||
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) {
|
||||
final AppPalette palette = _paletteOf(context);
|
||||
|
||||
if (_isMobileLayout) {
|
||||
return Material(
|
||||
color: palette.transparent,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
color: palette.cardBackground,
|
||||
child: _buildEditorContent(isMobile: true),
|
||||
),
|
||||
),
|
||||
final Widget editor = Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: _buildEditorBody(),
|
||||
);
|
||||
|
||||
if (widget.embedded) {
|
||||
return Container(color: palette.cardBackground, child: editor);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final double maxWidth = math.min(constraints.maxWidth - 32, 600);
|
||||
final double maxHeight = math.min(constraints.maxHeight - 32, 720);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ModalBarrier(dismissible: false, color: palette.shadowDim),
|
||||
return Scaffold(
|
||||
backgroundColor: palette.cardBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('Editar nota'),
|
||||
),
|
||||
Positioned.fill(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
body: SafeArea(child: editor),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+73
-113
@@ -4,150 +4,110 @@ import 'package:notas/data/note_body.dart';
|
||||
import 'package:notas/models/note.dart';
|
||||
import 'package:notas/theme/app_palette.dart';
|
||||
|
||||
// Small presentational widget for a note inside the grid.
|
||||
// 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 {
|
||||
class NoteCard extends StatelessWidget {
|
||||
const NoteCard({
|
||||
super.key,
|
||||
required this.note,
|
||||
this.onTap,
|
||||
this.isDragging = false,
|
||||
this.isSelected = false,
|
||||
this.borderColor,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
this.onChangeCategory,
|
||||
});
|
||||
|
||||
final Note note;
|
||||
final VoidCallback? onTap;
|
||||
final bool isDragging;
|
||||
final bool isSelected;
|
||||
final Color? borderColor;
|
||||
|
||||
@override
|
||||
State<NoteCard> createState() => _NoteCardState();
|
||||
}
|
||||
|
||||
class _NoteCardState extends State<NoteCard> {
|
||||
bool _isPressed = false;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDelete;
|
||||
final ValueChanged<BuildContext>? onChangeCategory;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AppPalette palette = Theme.of(context).extension<AppPalette>()!;
|
||||
final bool showGrabbing = widget.isDragging || _isPressed;
|
||||
final String bodyText = noteBodyToPlainText(note.body).trim();
|
||||
|
||||
return MouseRegion(
|
||||
cursor: showGrabbing
|
||||
? SystemMouseCursors.grabbing
|
||||
: SystemMouseCursors.grab,
|
||||
child: GestureDetector(
|
||||
onTapDown: widget.onTap == null
|
||||
? 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,
|
||||
return Material(
|
||||
color: Colors.transparent, // 1. Fondo completamente transparente
|
||||
shape: BorderDirectional(
|
||||
start: BorderSide(
|
||||
color: isSelected ? palette.accent : Colors.transparent,
|
||||
width: isSelected ? 1.6 : 1.0,
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// Estimate whether the body will exceed 20 lines without always
|
||||
// running the expensive TextPainter layout. This heuristic counts
|
||||
// newline characters and estimates wrapped lines based on an
|
||||
// average characters-per-line to handle many short lines well.
|
||||
final String bodyText = noteBodyToPlainText(widget.note.body);
|
||||
final List<String> rawLines = bodyText.split('\n');
|
||||
const int avgCharsPerLine = 40;
|
||||
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(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
hoverColor: Colors.transparent, // 2. Desactiva el efecto hover (pasar el ratón)
|
||||
splashColor: Colors.transparent, // 3. Desactiva el efecto de onda al hacer clic
|
||||
highlightColor: Colors.transparent, // Desactiva el brillo al mantener pulsado
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.note.title,
|
||||
note.title.isEmpty ? 'Sin título' : note.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: palette.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
bodyText,
|
||||
bodyText.isEmpty ? ' ' : bodyText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: palette.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 15,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
if (isBodyTruncated) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'...',
|
||||
style: TextStyle(
|
||||
color: palette.textMuted,
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,19 @@ 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', (
|
||||
testWidgets('autosaves a note when only the category changes', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
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(
|
||||
MaterialApp(
|
||||
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
|
||||
@@ -23,8 +31,8 @@ void main() {
|
||||
],
|
||||
home: Scaffold(
|
||||
body: NoteEditorScreen(
|
||||
note: null,
|
||||
categoryId: null,
|
||||
repository: null,
|
||||
note: initialNote,
|
||||
categories: <Category>[
|
||||
Category(
|
||||
id: 'work',
|
||||
@@ -32,8 +40,9 @@ void main() {
|
||||
updatedAt: DateTime(2026, 5, 21),
|
||||
),
|
||||
],
|
||||
onComplete: (dynamic result) {
|
||||
savedNote = result as Note?;
|
||||
saveNote: (Note note) async => note,
|
||||
onSaved: (Note result) {
|
||||
savedNote = result;
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -46,20 +55,26 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Trabajo').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Guardar'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
expect(savedNote, isNotNull);
|
||||
expect(savedNote!.categoryId, 'work');
|
||||
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,
|
||||
) 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(
|
||||
MaterialApp(
|
||||
@@ -71,25 +86,23 @@ void main() {
|
||||
],
|
||||
home: Scaffold(
|
||||
body: NoteEditorScreen(
|
||||
note: null,
|
||||
categoryId: null,
|
||||
repository: null,
|
||||
note: initialNote,
|
||||
categories: <Category>[],
|
||||
onComplete: (dynamic result) {
|
||||
if (result is Note) {
|
||||
completionCount += 1;
|
||||
}
|
||||
saveNote: (Note note) async {
|
||||
saveCount += 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'));
|
||||
await tester.tap(find.text('Guardar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(completionCount, 1);
|
||||
expect(saveCount, 1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user