432 lines
15 KiB
Dart
432 lines
15 KiB
Dart
import 'dart:math' as math;
|
|
|
|
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/app_title_bar.dart';
|
|
import 'package:notas/widgets/menu_drawer.dart';
|
|
import 'package:notas/screens/settings_screen.dart';
|
|
import 'package:notas/widgets/note_card.dart';
|
|
import 'package:notas/widgets/search_app_bar.dart';
|
|
|
|
// HomeScreen: main entry showing notes in a responsive masonry grid.
|
|
// Key behaviors implemented here:
|
|
// - Load/save notes via `NoteRepository` (SQLite through Drift)
|
|
// - Open `NoteEditorScreen` for create/edit
|
|
// - Drag & drop reordering (updates `index` in the database)
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({
|
|
super.key,
|
|
required this.repository,
|
|
required this.onDeleteAllData,
|
|
});
|
|
|
|
final NoteRepository repository;
|
|
final Future<void> Function() onDeleteAllData;
|
|
|
|
@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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadNotes();
|
|
}
|
|
|
|
Future<void> _loadNotes() async {
|
|
final List<Note> storedNotes = await widget.repository.loadNotes();
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_notes = storedNotes;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
: 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;
|
|
|
|
return 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 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: [
|
|
const AppTitleBar(),
|
|
SearchAppBar(
|
|
onMenuPressed: () {
|
|
setState(() {
|
|
_isMenuOpen = !_isMenuOpen;
|
|
});
|
|
},
|
|
onSearchChanged: (String query) {
|
|
setState(() {
|
|
_searchQuery = query;
|
|
});
|
|
},
|
|
),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
|
child: body,
|
|
),
|
|
// Dark overlay when menu is open; clicking it closes the menu.
|
|
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') {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => SettingsScreen(
|
|
onDeleteAllData: widget.onDeleteAllData,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |