Files
notas/lib/screens/home_screen.dart
T

662 lines
26 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:notas/data/note_repository.dart';
import 'package:notas/models/note.dart';
import 'package:notas/screens/note_editor_screen.dart';
import 'package:notas/widgets/menu_drawer.dart';
import 'package:notas/widgets/note_card.dart';
import 'package:notas/widgets/search_app_bar.dart';
import 'package:notas/widgets/sync_status.dart';
import 'package:notas/widgets/sync_status_indicator.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.repository,
required this.onOpenSettings,
required this.onRequestSync,
this.onVaultInvalid,
this.syncStatus = SyncStatus.idle,
this.syncProgress,
this.syncDetailMessage,
this.syncErrorMessage,
this.refreshToken = 0,
});
final NoteRepository repository;
final VoidCallback onOpenSettings;
final Future<void> Function() onRequestSync;
final Future<void> Function()? onVaultInvalid;
final SyncStatus syncStatus;
final double? syncProgress;
final String? syncDetailMessage;
final String? syncErrorMessage;
final int refreshToken;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
List<Note> _notes = <Note>[];
String _searchQuery = '';
bool _isLoading = true;
bool _isDragging = false;
bool _isMenuOpen = false;
PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse;
bool _requiresLongPressToDrag(PointerDeviceKind kind) {
return kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
}
@override
void initState() {
super.initState();
_loadNotes();
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshToken != widget.refreshToken) {
_loadNotes();
}
}
Future<void> _loadNotes() async {
try {
final List<Note> storedNotes = await widget.repository.loadNotes();
if (!mounted) return;
setState(() {
_notes = storedNotes;
_isLoading = false;
});
} catch (e) {
// If loading notes fails (e.g., DB corrupt), notify the app to reset the vault.
if (widget.onVaultInvalid != null) {
await widget.onVaultInvalid!();
}
}
}
Future<void> _openNoteComposer() async {
final dynamic result = await NoteEditorScreen.showDialog(context);
if (result == null) {
return;
}
if (result is Note) {
final Note createdNote = await widget.repository.createNote(result);
final List<Note> updatedNotes = _normalizeNotes(<Note>[
createdNote,
..._notes,
]);
if (!mounted) {
return;
}
setState(() {
_notes = updatedNotes;
});
// Trigger sync after creating a note.
try {
await widget.onRequestSync();
} catch (_) {}
}
}
Future<void> _deleteNote(Note note) async {
await widget.repository.deleteNote(note);
final List<Note> updatedNotes = _normalizeNotes(
_notes.where((Note item) => item.id != note.id).toList(),
);
if (!mounted) {
return;
}
setState(() {
_notes = updatedNotes;
});
// Trigger sync after deleting a note.
try {
await widget.onRequestSync();
} catch (_) {}
}
Future<void> _reorderNote(int oldIndex, int newIndex) async {
if (oldIndex == newIndex) {
return;
}
final List<Note> updatedNotes = [..._notes];
final Note movedNote = updatedNotes.removeAt(oldIndex);
updatedNotes.insert(newIndex, movedNote);
await widget.repository.moveNote(movedNote, newIndex);
if (!mounted) {
return;
}
setState(() {
_notes = _normalizeNotes(updatedNotes);
});
}
Future<void> _openNoteEditor(Note note) async {
final dynamic result = await NoteEditorScreen.showDialog(
context,
note: note,
);
if (result == null) {
return;
}
if (result == 'delete') {
await _deleteNote(note);
return;
}
if (result is Note) {
final int noteIndex = _notes.indexWhere((Note item) => item == note);
if (noteIndex != -1) {
final Note savedNote = await widget.repository.updateNote(result);
final List<Note> updatedNotes = [..._notes];
updatedNotes[noteIndex] = savedNote;
if (!mounted) {
return;
}
setState(() {
_notes = _normalizeNotes(updatedNotes);
});
// Trigger sync after editing a note.
try {
await widget.onRequestSync();
} catch (_) {}
}
}
}
List<Note> _normalizeNotes(List<Note> notes) {
return notes.asMap().entries.map((MapEntry<int, Note> entry) {
return entry.value.copyWith(index: entry.key);
}).toList();
}
List<Note> _getFilteredNotes() {
if (_searchQuery.isEmpty) {
return _notes;
}
final String query = _searchQuery.toLowerCase();
return _notes
.where(
(Note note) =>
note.title.toLowerCase().contains(query) ||
note.body.toLowerCase().contains(query),
)
.toList();
}
@override
Widget build(BuildContext context) {
final double width = MediaQuery.of(context).size.width;
final int crossAxisCount = math.max((width / 250).floor().round(), 2);
final Widget body = _isLoading
? const Center(child: CircularProgressIndicator())
: _notes.isEmpty
? const _EmptyState()
: RefreshIndicator(
onRefresh: () async {
await widget.onRequestSync();
},
child: MouseRegion(
cursor: _isDragging
? SystemMouseCursors.grabbing
: SystemMouseCursors.basic,
child: MasonryGridView.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
itemCount: _getFilteredNotes().length,
itemBuilder: (BuildContext context, int index) {
final List<Note> filteredNotes = _getFilteredNotes();
return DragTarget<int>(
onAcceptWithDetails: (DragTargetDetails<int> details) {
final Note targetNote = filteredNotes[index];
final int originalTargetIndex = _notes.indexOf(
targetNote,
);
_reorderNote(details.data, originalTargetIndex);
},
builder: (context, candidateData, rejectedData) {
return LayoutBuilder(
builder: (context, constraints) {
final double cellWidth = constraints.maxWidth;
final bool requiresLongPressToDrag =
_requiresLongPressToDrag(_lastPointerKind);
final Widget draggableNote = requiresLongPressToDrag
? LongPressDraggable<int>(
data: _notes.indexOf(filteredNotes[index]),
delay: const Duration(milliseconds: 280),
onDragStarted: () {
if (!mounted) return;
setState(() {
_isDragging = true;
});
},
onDragEnd: (_) {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
onDraggableCanceled: (_, _) {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
feedback: MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Material(
color: Colors.transparent,
elevation: 8,
child: SizedBox(
width: cellWidth,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.97, end: 1.0),
duration: const Duration(
milliseconds: 180,
),
curve: Curves.easeOutCubic,
builder: (context, scale, child) {
return Transform.scale(
scale: scale,
alignment: Alignment.topLeft,
child: child,
);
},
child: Opacity(
opacity: 0.95,
child: NoteCard(
note: filteredNotes[index],
onTap: () {},
isDragging: true,
),
),
),
),
),
),
childWhenDragging: MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Opacity(
opacity: 0.3,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color.fromRGBO(
24,
25,
26,
1,
),
borderRadius: BorderRadius.circular(
12,
),
border: Border.all(
color: Colors.white24,
width: 1,
),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
filteredNotes[index].title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
filteredNotes[index].body,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
maxLines: 20,
overflow: TextOverflow.clip,
),
],
),
),
),
),
child: Container(
decoration: BoxDecoration(
border: candidateData.isNotEmpty
? Border.all(
color: Colors.blue.shade400,
width: 2,
)
: null,
borderRadius: BorderRadius.circular(12),
),
child: NoteCard(
key: ValueKey<int>(
filteredNotes[index].id ??
filteredNotes[index].index,
),
note: filteredNotes[index],
onTap: () =>
_openNoteEditor(filteredNotes[index]),
isDragging: _isDragging,
),
),
)
: Draggable<int>(
data: _notes.indexOf(filteredNotes[index]),
onDragStarted: () {
if (!mounted) return;
setState(() {
_isDragging = true;
});
},
onDragEnd: (_) {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
onDraggableCanceled: (_, _) {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
feedback: MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Material(
color: Colors.transparent,
elevation: 8,
child: SizedBox(
width: cellWidth,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.97, end: 1.0),
duration: const Duration(
milliseconds: 180,
),
curve: Curves.easeOutCubic,
builder: (context, scale, child) {
return Transform.scale(
scale: scale,
alignment: Alignment.topLeft,
child: child,
);
},
child: Opacity(
opacity: 0.95,
child: NoteCard(
note: filteredNotes[index],
onTap: () {},
isDragging: true,
),
),
),
),
),
),
childWhenDragging: MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Opacity(
opacity: 0.3,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color.fromRGBO(
24,
25,
26,
1,
),
borderRadius: BorderRadius.circular(
12,
),
border: Border.all(
color: Colors.white24,
width: 1,
),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
filteredNotes[index].title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
filteredNotes[index].body,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
maxLines: 20,
overflow: TextOverflow.clip,
),
],
),
),
),
),
child: Container(
decoration: BoxDecoration(
border: candidateData.isNotEmpty
? Border.all(
color: Colors.blue.shade400,
width: 2,
)
: null,
borderRadius: BorderRadius.circular(12),
),
child: NoteCard(
key: ValueKey<int>(
filteredNotes[index].id ??
filteredNotes[index].index,
),
note: filteredNotes[index],
onTap: () =>
_openNoteEditor(filteredNotes[index]),
isDragging: _isDragging,
),
),
);
return Listener(
onPointerDown: (PointerDownEvent event) {
if (_lastPointerKind == event.kind) {
return;
}
setState(() {
_lastPointerKind = event.kind;
});
},
child: draggableNote,
);
},
);
},
);
},
),
),
);
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF191A1D), Color(0xFF222326), Color(0xFF101114)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Column(
children: [
SearchAppBar(
onMenuPressed: () {
setState(() {
_isMenuOpen = !_isMenuOpen;
});
},
trailingWidget: SyncStatusIndicator(
status: widget.syncStatus,
progress: widget.syncProgress,
detailMessage: widget.syncDetailMessage,
errorMessage: widget.syncErrorMessage,
onTap: widget.onRequestSync,
),
onSearchChanged: (String query) {
setState(() {
_searchQuery = query;
});
},
),
Expanded(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: body,
),
Positioned.fill(
child: IgnorePointer(
ignoring: !_isMenuOpen,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _isMenuOpen ? 0.5 : 0.0,
curve: Curves.easeOutCubic,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
_isMenuOpen = false;
});
},
child: Container(color: Colors.black),
),
),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: _isMenuOpen ? 0 : -280,
top: 0,
bottom: 0,
width: 280,
child: Material(
color: const Color.fromRGBO(24, 25, 26, 1),
elevation: 8,
child: MenuDrawer(
onMenuItemTapped: (String item) {
setState(() {
_isMenuOpen = false;
});
if (item == 'settings') {
widget.onOpenSettings();
}
},
),
),
),
],
),
),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _openNoteComposer,
child: const MouseRegion(
cursor: SystemMouseCursors.click,
child: Icon(Icons.add),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.note_add_outlined, color: Colors.white54, size: 48),
SizedBox(height: 12),
Text(
'Aún no hay notas',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 8),
Text(
'Pulsa el botón + para crear la primera.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
],
),
);
}
}