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}');
+597 -1067
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+87 -127
View File
@@ -4,150 +4,110 @@ 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(() { child: InkWell(
_isPressed = true; borderRadius: BorderRadius.circular(14),
}); onTap: onTap,
}, hoverColor: Colors.transparent, // 2. Desactiva el efecto hover (pasar el ratón)
onTapUp: widget.onTap == null splashColor: Colors.transparent, // 3. Desactiva el efecto de onda al hacer clic
? null highlightColor: Colors.transparent, // Desactiva el brillo al mantener pulsado
: (_) { child: Padding(
setState(() { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
_isPressed = false; child: Row(
}); crossAxisAlignment: CrossAxisAlignment.start,
}, children: [
onTapCancel: widget.onTap == null Expanded(
? null child: Column(
: () { mainAxisSize: MainAxisSize.min,
setState(() { crossAxisAlignment: CrossAxisAlignment.start,
_isPressed = false; children: [
}); Text(
}, note.title.isEmpty ? 'Sin título' : note.title,
onTap: widget.onTap, maxLines: 1,
child: Container( overflow: TextOverflow.ellipsis,
padding: const EdgeInsets.all(16), style: TextStyle(
decoration: BoxDecoration( color: palette.textPrimary,
color: palette.cardBackground, fontSize: 15,
borderRadius: BorderRadius.circular(12), fontWeight: FontWeight.w700,
border: Border.all( ),
color: widget.borderColor ?? palette.textDisabled, ),
width: 1, const SizedBox(height: 6),
Text(
bodyText.isEmpty ? ' ' : bodyText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textSecondary,
fontSize: 13,
height: 1.2,
),
),
],
), ),
), ),
child: LayoutBuilder( const SizedBox(width: 8),
builder: (BuildContext context, BoxConstraints constraints) { PopupMenuButton<String>(
// Estimate whether the body will exceed 20 lines without always tooltip: 'Más opciones',
// running the expensive TextPainter layout. This heuristic counts icon: Icon(
// newline characters and estimates wrapped lines based on an Icons.more_vert,
// average characters-per-line to handle many short lines well. color: palette.textSecondary,
final String bodyText = noteBodyToPlainText(widget.note.body); ),
final List<String> rawLines = bodyText.split('\n'); onOpened: () {},
const int avgCharsPerLine = 40; onSelected: (String value) {
int estimatedLines = 0; switch (value) {
for (final String line in rawLines) { case 'delete':
estimatedLines += (line.trim().length ~/ avgCharsPerLine) + 1; onDelete?.call();
return;
case 'category':
onChangeCategory?.call(context);
return;
} }
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,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.note.title,
style: TextStyle(
color: palette.textPrimary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
bodyText,
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,
),
),
],
],
);
}, },
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);
}); });
} }