Reestructuracion de la app
This commit is contained in:
@@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+239
-651
File diff suppressed because it is too large
Load Diff
+87
-127
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user